From 83c29f25e892325fbc60fbd25f6faae4d4fa544b Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 17 Jul 2026 15:40:30 +0200 Subject: [PATCH] EEPIC-#79 - v3 refactor: PSR-compliant client with typed DTOs, repositories, enums, PHP 8.1+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Breaking Changes (v2 → v3) ### Architecture - ZammadClient with typed factory methods replaces Client (arrays, magic strings) - Repository pattern + typed DTOs replaces Resource + ResourceType enums - Typed exceptions (NotFoundException, etc.) replaces hasError()/getError() - PHP >= 8.1 (was PHP >= 7.2) ### Files removed (~3,900 LOC) - src/Client.php, src/HTTPClient.php, src/HTTPClientInterface.php - src/Resource/ — all 10 resource classes - src/ResourceType.php, src/Exception/ - test/ZammadAPIClient/ — old test suite - examples/ticket.php, examples/user.php, examples/v2-usage.php ## What's new (~10,200 LOC) ### Core (src/Core/) - ZammadClient, RequestHandler, RetryAfterMiddleware, AbstractRepository, PaginatedList, Resource, DtoHydrator, Cast, ConnectionConfig, RepositoryRegistry, ResponseParser, HttpPageFetcher, traits ### Contracts (src/Core/Contracts/) - RepositoryInterface, DTOInterface, DeletableInterface, PatchableInterface, RequestHandlerInterface, ClientInterface, PageFetcherInterface ### Endpoints — 10 repositories + typed DTOs (src/Endpoints/) - Tickets (CRUD + delete + getTicketArticles), TicketDTO, TicketUpdateDTO - Users (CRUD + delete + CSV import), UserDTO - Organizations (CRUD + delete + CSV import), OrganizationDTO - Groups (CRUD + delete), GroupDTO - TicketArticles (getForTicket, getAttachmentContent), TicketArticleDTO, TicketArticleType enum (Note, Email, Phone, Sms, Web) - TicketStates (read-only), TicketStateDTO - TicketPriorities (read-only), TicketPriorityDTO - Tags (add, remove, tagSearch), TagDTO - TextModules (CRUD + delete + CSV import), TextModuleDTO - Links (add, remove, list), LinkDTO ### Exceptions — typed hierarchy (src/Exceptions/) - AuthenticationException (401), ForbiddenException (403), NotFoundException (404), ValidationException (422, with $errors array), RateLimitException (429, auto-retried), ServerErrorException (5xx), NetworkException ### Bridge (src/Bridge/) - LaravelServiceProvider, SymfonyBundle ### Tests — 227 unit tests, 12 integration test classes ### Documentation - docs/migration-v3.md, docs/migration-v3-examples.md (15 side-by-side examples) - docs/v2-reference.md, docs/alternative-clients.md - examples/cookbook.php (9 runnable v3 recipes) - README.md with DTO field tables, update decision guide, error mapping, paradigm guide, delete() availability table --- .github/workflows/ci.yml | 61 -- .github/workflows/tests.yml | 69 ++ .gitignore | 8 +- .opencode/plans/refactoring-plan.md | 589 +++++++++++++ CHANGELOG.md | 59 +- Makefile | 48 ++ README.md | 777 ++++++++++++------ bin/install-git-hooks | 15 + bin/pre-commit | 11 + composer.json | 60 +- config/zammad.php | 17 + docs/alternative-clients.md | 144 ++++ docs/migration-v3-examples.md | 386 +++++++++ docs/migration-v3.md | 91 ++ docs/v2-reference.md | 286 +++++++ examples/config.php.dist | 17 - examples/cookbook.php | 146 ++++ examples/tag_admin.php | 68 -- examples/ticket.php | 73 -- examples/user.php | 58 -- phpcs.xml.dist | 16 + phpstan.neon.dist | 13 + phpunit.xml.dist | 49 +- src/Bridge/LaravelServiceProvider.php | 94 +++ src/Bridge/SymfonyBundle.php | 108 +++ src/Client.php | 237 ------ src/Client/Response.php | 86 -- src/Core/AbstractRepository.php | 356 ++++++++ src/Core/Cast.php | 114 +++ src/Core/ConnectionConfig.php | 19 + src/Core/Contracts/ClientInterface.php | 27 + src/Core/Contracts/DTOInterface.php | 71 ++ src/Core/Contracts/DeletableInterface.php | 23 + src/Core/Contracts/PageFetcherInterface.php | 30 + src/Core/Contracts/PatchableInterface.php | 15 + src/Core/Contracts/RepositoryInterface.php | 86 ++ .../Contracts/RequestHandlerInterface.php | 111 +++ src/Core/DtoHydrator.php | 137 +++ src/Core/HttpPageFetcher.php | 112 +++ src/Core/PaginatedList.php | 209 +++++ src/Core/RepositoryRegistry.php | 72 ++ src/Core/RequestHandler.php | 340 ++++++++ src/Core/Resource.php | 165 ++++ src/Core/ResponseParser.php | 44 + src/Core/RetryAfterMiddleware.php | 91 ++ src/Core/Traits/HasTimestamps.php | 31 + src/Core/Traits/HydratesFromArray.php | 39 + src/Core/Traits/SerializesToArray.php | 128 +++ src/Endpoints/Groups/GroupDTO.php | 43 + src/Endpoints/Groups/GroupRepository.php | 30 + src/Endpoints/Links/LinkDTO.php | 40 + src/Endpoints/Links/LinkRepository.php | 120 +++ .../Organizations/OrganizationDTO.php | 43 + .../Organizations/OrganizationRepository.php | 51 ++ src/Endpoints/Tags/TagDTO.php | 43 + src/Endpoints/Tags/TagRepository.php | 187 +++++ src/Endpoints/TextModules/TextModuleDTO.php | 47 ++ .../TextModules/TextModuleRepository.php | 40 + .../TicketArticles/TicketArticleDTO.php | 95 +++ .../TicketArticleRepository.php | 80 ++ .../TicketArticles/TicketArticleType.php | 37 + .../TicketPriorities/TicketPriorityDTO.php | 39 + .../TicketPriorityRepository.php | 28 + src/Endpoints/TicketStates/TicketStateDTO.php | 45 + .../TicketStates/TicketStateRepository.php | 29 + src/Endpoints/Tickets/TicketDTO.php | 78 ++ src/Endpoints/Tickets/TicketRepository.php | 56 ++ src/Endpoints/Tickets/TicketUpdateDTO.php | 55 ++ src/Endpoints/Users/UserDTO.php | 59 ++ src/Endpoints/Users/UserRepository.php | 54 ++ src/Exception/AbstractException.php | 13 - .../AlreadyFetchedObjectException.php | 13 - src/Exceptions/AuthenticationException.php | 22 + src/Exceptions/ForbiddenException.php | 26 + src/Exceptions/NetworkException.php | 25 + src/Exceptions/NotFoundException.php | 22 + src/Exceptions/RateLimitException.php | 31 + src/Exceptions/ServerErrorException.php | 24 + src/Exceptions/ValidationException.php | 34 + src/Exceptions/ZammadException.php | 25 + src/HTTPClient.php | 200 ----- src/HTTPClientInterface.php | 10 - src/Resource/AbstractResource.php | 658 --------------- src/Resource/Group.php | 19 - src/Resource/Link.php | 145 ---- src/Resource/Organization.php | 50 -- src/Resource/Tag.php | 184 ----- src/Resource/TextModule.php | 49 -- src/Resource/Ticket.php | 44 - src/Resource/TicketArticle.php | 125 --- src/Resource/TicketPriority.php | 19 - src/Resource/TicketState.php | 19 - src/Resource/User.php | 50 -- src/ResourceType.php | 22 - src/ZammadClient.php | 298 +++++++ .../ErrorHandlingIntegrationTest.php | 50 ++ test/Integration/GroupIntegrationTest.php | 66 ++ test/Integration/LinkIntegrationTest.php | 96 +++ .../OrganizationIntegrationTest.php | 66 ++ .../Integration/PaginationIntegrationTest.php | 131 +++ .../ReferenceDataIntegrationTest.php | 81 ++ test/Integration/SearchIntegrationTest.php | 56 ++ test/Integration/TagIntegrationTest.php | 101 +++ .../TicketArticleIntegrationTest.php | 90 ++ test/Integration/TicketIntegrationTest.php | 198 +++++ .../Traits/CreatesZammadClient.php | 39 + test/Integration/UserIntegrationTest.php | 98 +++ test/Unit/Core/AbstractRepositoryTest.php | 349 ++++++++ test/Unit/Core/CastTest.php | 116 +++ test/Unit/Core/ConnectionConfigTest.php | 43 + test/Unit/Core/DtoHydratorTest.php | 98 +++ test/Unit/Core/HttpPageFetcherTest.php | 70 ++ test/Unit/Core/PaginatedListTest.php | 241 ++++++ test/Unit/Core/RepositoryRegistryTest.php | 42 + test/Unit/Core/RequestHandlerTest.php | 267 ++++++ test/Unit/Core/ResourceTest.php | 164 ++++ test/Unit/Core/ResponseParserTest.php | 54 ++ test/Unit/Core/RetryAfterMiddlewareTest.php | 133 +++ .../Core/Traits/CreatesRequestHandler.php | 29 + test/Unit/Core/Traits/HasTimestampsTest.php | 45 + .../Core/Traits/HydratesFromArrayTest.php | 62 ++ .../Core/Traits/SerializesToArrayTest.php | 126 +++ test/Unit/DTOs/DTOTest.php | 235 ++++++ test/Unit/DTOs/TicketArticleDTOTest.php | 147 ++++ test/Unit/DTOs/TicketArticleTypeTest.php | 47 ++ test/Unit/DTOs/TicketTest.php | 160 ++++ test/Unit/DTOs/TicketUpdateDTOTest.php | 91 ++ .../Unit/Repositories/GroupRepositoryTest.php | 80 ++ test/Unit/Repositories/LinkRepositoryTest.php | 87 ++ .../OrganizationRepositoryTest.php | 64 ++ test/Unit/Repositories/TagRepositoryTest.php | 79 ++ .../Repositories/TextModuleRepositoryTest.php | 64 ++ .../TicketArticleRepositoryTest.php | 37 + .../TicketPriorityRepositoryTest.php | 38 + .../Repositories/TicketRepositoryTest.php | 54 ++ .../TicketStateRepositoryTest.php | 53 ++ test/Unit/Repositories/UserRepositoryTest.php | 79 ++ test/Unit/ZammadClientTest.php | 185 +++++ test/ZammadAPIClient/Client/ResponseTest.php | 97 --- test/ZammadAPIClient/ClientTest.php | 105 --- test/ZammadAPIClient/EnvConfigTrait.php | 39 - test/ZammadAPIClient/GetIDTest.php | 65 -- .../Resource/AbstractBaseTest.php | 440 ---------- test/ZammadAPIClient/Resource/GroupTest.php | 41 - test/ZammadAPIClient/Resource/LinkTest.php | 307 ------- .../Resource/OrganizationTest.php | 93 --- test/ZammadAPIClient/Resource/TagTest.php | 323 -------- .../Resource/TextModuleTest.php | 96 --- .../Resource/TicketArticleTest.php | 238 ------ .../Resource/TicketPriorityTest.php | 41 - .../Resource/TicketStateTest.php | 44 - test/ZammadAPIClient/Resource/TicketTest.php | 292 ------- test/ZammadAPIClient/Resource/UserTest.php | 96 --- .../Resource/organizations_import.csv | 3 - test/ZammadAPIClient/Resource/test_file.jpg | Bin 1004 -> 0 bytes test/ZammadAPIClient/Resource/test_file.txt | 1 - .../Resource/text_modules_import.csv | 3 - .../ZammadAPIClient/Resource/users_import.csv | 3 - test/bootstrap.php | 27 +- 159 files changed, 11127 insertions(+), 4870 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .opencode/plans/refactoring-plan.md create mode 100644 Makefile create mode 100755 bin/install-git-hooks create mode 100755 bin/pre-commit create mode 100644 config/zammad.php create mode 100644 docs/alternative-clients.md create mode 100644 docs/migration-v3-examples.md create mode 100644 docs/migration-v3.md create mode 100644 docs/v2-reference.md delete mode 100644 examples/config.php.dist create mode 100644 examples/cookbook.php delete mode 100644 examples/tag_admin.php delete mode 100644 examples/ticket.php delete mode 100644 examples/user.php create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 src/Bridge/LaravelServiceProvider.php create mode 100644 src/Bridge/SymfonyBundle.php delete mode 100644 src/Client.php delete mode 100644 src/Client/Response.php create mode 100644 src/Core/AbstractRepository.php create mode 100644 src/Core/Cast.php create mode 100644 src/Core/ConnectionConfig.php create mode 100644 src/Core/Contracts/ClientInterface.php create mode 100644 src/Core/Contracts/DTOInterface.php create mode 100644 src/Core/Contracts/DeletableInterface.php create mode 100644 src/Core/Contracts/PageFetcherInterface.php create mode 100644 src/Core/Contracts/PatchableInterface.php create mode 100644 src/Core/Contracts/RepositoryInterface.php create mode 100644 src/Core/Contracts/RequestHandlerInterface.php create mode 100644 src/Core/DtoHydrator.php create mode 100644 src/Core/HttpPageFetcher.php create mode 100644 src/Core/PaginatedList.php create mode 100644 src/Core/RepositoryRegistry.php create mode 100644 src/Core/RequestHandler.php create mode 100644 src/Core/Resource.php create mode 100644 src/Core/ResponseParser.php create mode 100644 src/Core/RetryAfterMiddleware.php create mode 100644 src/Core/Traits/HasTimestamps.php create mode 100644 src/Core/Traits/HydratesFromArray.php create mode 100644 src/Core/Traits/SerializesToArray.php create mode 100644 src/Endpoints/Groups/GroupDTO.php create mode 100644 src/Endpoints/Groups/GroupRepository.php create mode 100644 src/Endpoints/Links/LinkDTO.php create mode 100644 src/Endpoints/Links/LinkRepository.php create mode 100644 src/Endpoints/Organizations/OrganizationDTO.php create mode 100644 src/Endpoints/Organizations/OrganizationRepository.php create mode 100644 src/Endpoints/Tags/TagDTO.php create mode 100644 src/Endpoints/Tags/TagRepository.php create mode 100644 src/Endpoints/TextModules/TextModuleDTO.php create mode 100644 src/Endpoints/TextModules/TextModuleRepository.php create mode 100644 src/Endpoints/TicketArticles/TicketArticleDTO.php create mode 100644 src/Endpoints/TicketArticles/TicketArticleRepository.php create mode 100644 src/Endpoints/TicketArticles/TicketArticleType.php create mode 100644 src/Endpoints/TicketPriorities/TicketPriorityDTO.php create mode 100644 src/Endpoints/TicketPriorities/TicketPriorityRepository.php create mode 100644 src/Endpoints/TicketStates/TicketStateDTO.php create mode 100644 src/Endpoints/TicketStates/TicketStateRepository.php create mode 100644 src/Endpoints/Tickets/TicketDTO.php create mode 100644 src/Endpoints/Tickets/TicketRepository.php create mode 100644 src/Endpoints/Tickets/TicketUpdateDTO.php create mode 100644 src/Endpoints/Users/UserDTO.php create mode 100644 src/Endpoints/Users/UserRepository.php delete mode 100644 src/Exception/AbstractException.php delete mode 100644 src/Exception/AlreadyFetchedObjectException.php create mode 100644 src/Exceptions/AuthenticationException.php create mode 100644 src/Exceptions/ForbiddenException.php create mode 100644 src/Exceptions/NetworkException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/RateLimitException.php create mode 100644 src/Exceptions/ServerErrorException.php create mode 100644 src/Exceptions/ValidationException.php create mode 100644 src/Exceptions/ZammadException.php delete mode 100644 src/HTTPClient.php delete mode 100644 src/HTTPClientInterface.php delete mode 100644 src/Resource/AbstractResource.php delete mode 100644 src/Resource/Group.php delete mode 100644 src/Resource/Link.php delete mode 100644 src/Resource/Organization.php delete mode 100644 src/Resource/Tag.php delete mode 100644 src/Resource/TextModule.php delete mode 100644 src/Resource/Ticket.php delete mode 100644 src/Resource/TicketArticle.php delete mode 100644 src/Resource/TicketPriority.php delete mode 100644 src/Resource/TicketState.php delete mode 100644 src/Resource/User.php delete mode 100644 src/ResourceType.php create mode 100644 src/ZammadClient.php create mode 100644 test/Integration/ErrorHandlingIntegrationTest.php create mode 100644 test/Integration/GroupIntegrationTest.php create mode 100644 test/Integration/LinkIntegrationTest.php create mode 100644 test/Integration/OrganizationIntegrationTest.php create mode 100644 test/Integration/PaginationIntegrationTest.php create mode 100644 test/Integration/ReferenceDataIntegrationTest.php create mode 100644 test/Integration/SearchIntegrationTest.php create mode 100644 test/Integration/TagIntegrationTest.php create mode 100644 test/Integration/TicketArticleIntegrationTest.php create mode 100644 test/Integration/TicketIntegrationTest.php create mode 100644 test/Integration/Traits/CreatesZammadClient.php create mode 100644 test/Integration/UserIntegrationTest.php create mode 100644 test/Unit/Core/AbstractRepositoryTest.php create mode 100644 test/Unit/Core/CastTest.php create mode 100644 test/Unit/Core/ConnectionConfigTest.php create mode 100644 test/Unit/Core/DtoHydratorTest.php create mode 100644 test/Unit/Core/HttpPageFetcherTest.php create mode 100644 test/Unit/Core/PaginatedListTest.php create mode 100644 test/Unit/Core/RepositoryRegistryTest.php create mode 100644 test/Unit/Core/RequestHandlerTest.php create mode 100644 test/Unit/Core/ResourceTest.php create mode 100644 test/Unit/Core/ResponseParserTest.php create mode 100644 test/Unit/Core/RetryAfterMiddlewareTest.php create mode 100644 test/Unit/Core/Traits/CreatesRequestHandler.php create mode 100644 test/Unit/Core/Traits/HasTimestampsTest.php create mode 100644 test/Unit/Core/Traits/HydratesFromArrayTest.php create mode 100644 test/Unit/Core/Traits/SerializesToArrayTest.php create mode 100644 test/Unit/DTOs/DTOTest.php create mode 100644 test/Unit/DTOs/TicketArticleDTOTest.php create mode 100644 test/Unit/DTOs/TicketArticleTypeTest.php create mode 100644 test/Unit/DTOs/TicketTest.php create mode 100644 test/Unit/DTOs/TicketUpdateDTOTest.php create mode 100644 test/Unit/Repositories/GroupRepositoryTest.php create mode 100644 test/Unit/Repositories/LinkRepositoryTest.php create mode 100644 test/Unit/Repositories/OrganizationRepositoryTest.php create mode 100644 test/Unit/Repositories/TagRepositoryTest.php create mode 100644 test/Unit/Repositories/TextModuleRepositoryTest.php create mode 100644 test/Unit/Repositories/TicketArticleRepositoryTest.php create mode 100644 test/Unit/Repositories/TicketPriorityRepositoryTest.php create mode 100644 test/Unit/Repositories/TicketRepositoryTest.php create mode 100644 test/Unit/Repositories/TicketStateRepositoryTest.php create mode 100644 test/Unit/Repositories/UserRepositoryTest.php create mode 100644 test/Unit/ZammadClientTest.php delete mode 100644 test/ZammadAPIClient/Client/ResponseTest.php delete mode 100644 test/ZammadAPIClient/ClientTest.php delete mode 100644 test/ZammadAPIClient/EnvConfigTrait.php delete mode 100644 test/ZammadAPIClient/GetIDTest.php delete mode 100644 test/ZammadAPIClient/Resource/AbstractBaseTest.php delete mode 100644 test/ZammadAPIClient/Resource/GroupTest.php delete mode 100644 test/ZammadAPIClient/Resource/LinkTest.php delete mode 100644 test/ZammadAPIClient/Resource/OrganizationTest.php delete mode 100644 test/ZammadAPIClient/Resource/TagTest.php delete mode 100644 test/ZammadAPIClient/Resource/TextModuleTest.php delete mode 100644 test/ZammadAPIClient/Resource/TicketArticleTest.php delete mode 100644 test/ZammadAPIClient/Resource/TicketPriorityTest.php delete mode 100644 test/ZammadAPIClient/Resource/TicketStateTest.php delete mode 100644 test/ZammadAPIClient/Resource/TicketTest.php delete mode 100644 test/ZammadAPIClient/Resource/UserTest.php delete mode 100644 test/ZammadAPIClient/Resource/organizations_import.csv delete mode 100644 test/ZammadAPIClient/Resource/test_file.jpg delete mode 100644 test/ZammadAPIClient/Resource/test_file.txt delete mode 100644 test/ZammadAPIClient/Resource/text_modules_import.csv delete mode 100644 test/ZammadAPIClient/Resource/users_import.csv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4cb37a4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: CI -on: - push: - branches: [master] - pull_request: - schedule: - # Run every on Friday to ensure everything works as expected. - - cron: '0 6 * * 5' -jobs: - CI: - runs-on: ubuntu-latest - container: - image: zammad/zammad-ci:latest - services: - postgresql: - image: postgres:17 - env: - POSTGRES_USER: zammad - POSTGRES_PASSWORD: zammad - redis: - image: redis:7 - env: - ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL: "http://localhost:3000" - ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME: "admin@example.com" - ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD: "test" - strategy: - fail-fast: false - matrix: - php: ['7.4', '7.3', '7.2', '8.0', '8.1', '8.2', '8.3', '8.4'] - name: PHP ${{ matrix.php }} - steps: - - uses: actions/checkout@v7 - - name: Install PHP - uses: shivammathur/setup-php@v2 - env: - fail-fast: true - with: - php-version: ${{ matrix.php }} - - name: Report PHP version - run: php -v - - name: Install dependencies - shell: bash - run: | - composer install - - name: Set up Zammad - shell: bash - run: | - git clone --depth 1 https://github.com/zammad/zammad.git - cd zammad - source /etc/profile.d/rvm.sh # ensure RVM is loaded - bundle config set --local frozen 'true' - bundle config set --local path 'vendor' - bundle install -j $(nproc) - bundle exec ruby .gitlab/configure_environment.rb - source .gitlab/environment.env - RAILS_ENV=test bundle exec rake db:create - RAILS_ENV=test bundle exec rake zammad:ci:test:start zammad:setup:auto_wizard - - name: Run PHP API integration tests - shell: bash - run: | - vendor/bin/phpunit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7efaf50 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,69 @@ +name: Tests + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: xdebug + - run: composer install --no-interaction + - run: composer dump-autoload + - run: | + if [ -d src ]; then vendor/bin/phpcs --standard=PSR12 src/; else echo "→ Skip (src/ not yet present)"; fi + - run: | + if [ -d src ]; then vendor/bin/phpstan analyse src/ --level=max; else echo "→ Skip (src/ not yet present)"; fi + - run: | + if [ -d test/Unit ]; then vendor/bin/phpunit --testsuite=unit --display-skipped; else echo "→ Skip (test/Unit/ not yet present)"; fi + + integration: + runs-on: ubuntu-latest + continue-on-error: true + container: + image: zammad/zammad-ci:latest + services: + postgresql: + image: postgres:17 + env: + POSTGRES_USER: zammad + POSTGRES_PASSWORD: zammad + redis: + image: redis:7 + steps: + - uses: actions/checkout@v5 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + - name: Install dependencies + run: composer install --no-interaction + - name: Dump autoload + run: composer dump-autoload + - name: Set up Zammad + shell: bash + run: | + git clone --depth 1 https://github.com/zammad/zammad.git + cd zammad + source /etc/profile.d/rvm.sh + bundle config set --local frozen 'true' + bundle config set --local path 'vendor' + bundle install -j $(nproc) + bundle exec ruby .gitlab/configure_environment.rb + source .gitlab/environment.env + RAILS_ENV=test bundle exec rake db:create + RAILS_ENV=test bundle exec rake zammad:ci:test:start zammad:setup:auto_wizard + - name: Run integration tests + run: | + set -x + if [ -d test/Integration ]; then vendor/bin/phpunit --testsuite=integration --group=integration --display-skipped; else echo "→ Skip (test/Integration/ not yet present)"; fi + env: + ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL: http://localhost:3000/api/v1 + ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME: admin@example.com + ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD: test diff --git a/.gitignore b/.gitignore index 84192e7..641b94b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,4 @@ /vendor/ -/phpunit.xml +/.phpunit.cache/ /composer.lock -/examples/config.php -/examples/test.php -/test*.php -*~ -.phpunit.result.cache \ No newline at end of file +/build/ diff --git a/.opencode/plans/refactoring-plan.md b/.opencode/plans/refactoring-plan.md new file mode 100644 index 0000000..15c4812 --- /dev/null +++ b/.opencode/plans/refactoring-plan.md @@ -0,0 +1,589 @@ +# Refactoring Plan: `ZammadClient` in drei Rollen zerlegen + +## 1. Motivation + +Die aktuelle `ZammadClient`-Klasse vereint mehrere widerspruchliche Zustandigkeiten in einer Datei: + +- **`RepositoryRegistry` behauptet "single source of truth"** fur Path- und DTO-Mapping — wird aber durch `ZammadClient::aliasMap()` dupliziert. +- **`createClient()` akzeptiert PSR-18-Theorie** (beliebiger `ClientInterface`) — hardcodet aber `new GuzzleClient()` und `new HttpFactory()`. +- **`aliasMap()` / `resolveAlias()` / `__call()`** sind bereits als deprecated markiert, aber noch im Client eingebettet. +- **Impersonation-Methoden** (`setOnBehalfOfUser`, `performOnBehalfOf`, etc.) gehoren als Transport-Concern in den `RequestHandler`, nicht in den Client. +- **`getListKey()`** ist in allen 10 Repository-Implementierungen identisch mit `$this->resourcePath` — 10-fache Redundanz. + +Ziel: **Eine Klasse = eine Verantwortung.** + +--- + +## 2. Architektur-Ubersicht + +### Vorher + +``` +ZammadClient (6 Rollen in einer Klasse) + ├── Factory-Methoden → withToken, withOAuth2, withBasicAuth, withClient + ├── Guzzle-Wiring → createClient, normalizeUrl + ├── Repository-Locator → repo, repository, $repos + ├── Alias-Auflosung → __call, aliasMap, resolveAlias [deprecated] + ├── Impersonation → setOnBehalfOf, unsetOnBehalfOf, performOnBehalfOf + └── Handler-Zugriff → getHandler +``` + +### Nachher + +``` +ClientFactory (Guzzle-Wiring + Factory-Methoden) + ├── withToken, withOAuth2, withBasicAuth → hardcoden Guzzle + ├── withClient → PSR-18-agnostisch + ├── createClient (private) → einzige Guzzle-Stelle + └── normalizeUrl (private) + +ZammadClient (schlanker Repository-Locator) + ├── repo, repository, $repos + └── getHandler + +RequestHandlerInterface (Transport) + ├── request, get, post, put, delete, getRaw, getLastResponse + ├── setOnBehalfOfUser, getOnBehalfOfUser + └── performOnBehalfOf [NEU: von ZammadClient hierher] + +AbstractRepository (Basis-Repo) + └── getListKey() → default $this->resourcePath [war vorher 10x identisch implementiert] + +RepositoryRegistry (unverandert — single source of truth) + └── DEFINITIONS: RepoClass → [path, dto] +``` + +--- + +## 3. Detaillierte Anderungen + +### 3.1 NEU: `src/ClientFactory.php` + +```php +logger ?? new NullLogger(), + maxRetries: $config->maxRetries, + ); + + return new ZammadClient($handler); + } + + private static function normalizeUrl(string $url): string + { + $url = rtrim($url, '/'); + + if (!str_contains($url, '/api/')) { + $url .= '/api/v1'; + } + + return $url; + } + + private static function createClient( + string $url, + ConnectionConfig $config, + string $authHeader, + ): ZammadClient { + $url = self::normalizeUrl($url); + + $headers = [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => $authHeader, + ]; + + $httpClient = new GuzzleClient([ + 'headers' => $headers, + 'verify' => $config->verifySsl, + 'timeout' => $config->timeout, + 'connect_timeout' => $config->connectTimeout, + 'allow_redirects' => false, + ]); + + $handler = new RequestHandler( + $httpClient, + new HttpFactory(), + $url, + logger: $config->logger ?? new NullLogger(), + maxRetries: $config->maxRetries, + ); + + return new ZammadClient($handler); + } +} +``` + +- **Wichtig**: `createClient()` ist der **einzige Ort im gesamten Codebase**, der Guzzle direkt instantiiert (`new GuzzleClient`, `new HttpFactory`). +- Die Klasse ist `final`, hat nur statische Methoden, keine Properties — reine Factory. +- Alle Ruckgabetypen sind `ZammadClient`. + +### 3.2 MODIFY: `src/ZammadClient.php` + +**Entfernt (ersatzlos oder verschoben):** + +| Methode / Property | Ziel | Grund | +|---|---|---| +| `USER_AGENT` const | `ClientFactory` | Gehort zur Factory | +| `withToken()` | `ClientFactory` | Guzzle-Wiring | +| `withOAuth2()` | `ClientFactory` | Guzzle-Wiring | +| `withBasicAuth()` | `ClientFactory` | Guzzle-Wiring | +| `withClient()` | `ClientFactory` | Gehort konzeptuell zur Factory | +| `createClient()` | `ClientFactory` | Guzzle-Wiring | +| `normalizeUrl()` | `ClientFactory` | URL-Normalisierung ist Factory-Concern | +| `__call()` | **geloscht** | Deprecated; wird in v4.0 entfernt | +| `aliasMap()` | **geloscht** | Deprecated | +| `resolveAlias()` | **geloscht** | Deprecated | +| `setOnBehalfOfUser()` | **geloscht** (war bereits auf Handler) | Transport-Concern | +| `unsetOnBehalfOfUser()` | **geloscht** (war bereits auf Handler) | Transport-Concern | +| `performOnBehalfOf()` | `RequestHandler` (siehe 3.3) | Transport-Concern | + +Entfernte Imports: +- `GuzzleHttp\Client as GuzzleClient` +- `GuzzleHttp\Psr7\HttpFactory` +- `InvalidArgumentException` (nur fur `__call` benotigt) +- `Psr\Http\Client\ClientInterface` +- `Psr\Http\Message\RequestFactoryInterface` +- `Psr\Http\Message\StreamFactoryInterface` +- `Psr\Log\NullLogger` +- `ZammadAPIClient\Core\ConnectionConfig` +- `ZammadAPIClient\Core\Contracts\DTOInterface` + +**Bleibt (unverandert):** + +```php + */ + private array $repos = []; + + public function __construct( + private RequestHandlerInterface $handler, + ) { + } + + public function getHandler(): RequestHandlerInterface + { + return $this->handler; + } + + /** + * @template T of AbstractRepository + * @param class-string $repositoryClass + * @return T + */ + public function repo(string $repositoryClass): AbstractRepository + { + $definition = RepositoryRegistry::definition($repositoryClass); + + return $this->repository($repositoryClass, $definition['path'], $definition['dto']); + } + + /** + * @template T of AbstractRepository + * @param class-string $repoClass + * @param class-string<\ZammadAPIClient\Core\Contracts\DTOInterface> $dtoClass + * @return T + */ + private function repository(string $repoClass, string $path, string $dtoClass): object + { + if (!isset($this->repos[$repoClass])) { + $this->repos[$repoClass] = new $repoClass($this->handler, $path, $dtoClass); + } + + /** @var T $repo */ + $repo = $this->repos[$repoClass]; + + return $repo; + } +} +``` + +**Ergebnis**: ~50 Zeilen (vorher ~210 Zeilen). Die Klasse macht genau eine Sache: `repo()` und `getHandler()`. + +### 3.3 MODIFY: `performOnBehalfOf()` auf `RequestHandler` verschieben + +#### 3.3a: `src/Core/Contracts/RequestHandlerInterface.php` + +**Hinzufugen:** + +```php +/** + * Executes a closure with a temporary impersonation header. + * + * The header is set before the callback and restored afterwards. + * + * @param int|string $userId User ID, login, or email to impersonate. + * @param callable $callback Closure to execute while impersonating. + * @return mixed Return value of the callback. + */ +public function performOnBehalfOf(int|string $userId, callable $callback): mixed; +``` + +Vorhandene `setOnBehalfOfUser` und `getOnBehalfOfUser` bleiben unverandert. + +#### 3.3b: `src/Core/RequestHandler.php` + +**Implementierung hinzufugen:** + +```php +public function performOnBehalfOf(int|string $userId, callable $callback): mixed +{ + $previous = $this->onBehalfOfUser; + + $this->onBehalfOfUser = $userId; + + try { + return $callback(); + } finally { + $this->onBehalfOfUser = $previous; + } +} +``` + +**Hinweis**: Anders als in `ZammadClient` wird hier **kein Argument** an den Callback ubergeben. Der alte Code reichte `$this` (den Client), aber das war fur die tatsachliche Nutzung irrelevant — alle aktuellen Aufrufe verwenden `use`-Closures. + +### 3.4 MODIFY: `getListKey()` in `AbstractRepository` als Default + +Datei: `src/Core/AbstractRepository.php` + +**Anderung**: Aus `abstract` wird eine konkrete Methode mit Default: + +```php +// Vorher (abstract): +abstract protected function getListKey(): string; + +// Nachher (konkret mit Default): +protected function getListKey(): string +{ + return $this->resourcePath; +} +``` + +**Begrundung**: In allen 10 Repos ist `getListKey()` identisch mit `$this->resourcePath`. Die Subklassen-Uberschreibungen sind reine Redundanz. + +### 3.5 MODIFY: `getListKey()` aus allen Repository-Klassen entfernen + +Diese Methode wird aus allen 10 Repository-Dateien ersatzlos gestrichen: + +| Datei | Zu entfernender Code | +|---|---| +| `src/Endpoints/Tickets/TicketRepository.php` | `protected function getListKey(): string { return 'tickets'; }` | +| `src/Endpoints/Users/UserRepository.php` | `protected function getListKey(): string { return 'users'; }` | +| `src/Endpoints/Groups/GroupRepository.php` | `protected function getListKey(): string { return 'groups'; }` | +| `src/Endpoints/Organizations/OrganizationRepository.php` | `protected function getListKey(): string { return 'organizations'; }` | +| `src/Endpoints/Links/LinkRepository.php` | `protected function getListKey(): string { return 'links'; }` | +| `src/Endpoints/TicketArticles/TicketArticleRepository.php` | `protected function getListKey(): string { return 'ticket_articles'; }` | +| `src/Endpoints/TicketStates/TicketStateRepository.php` | `protected function getListKey(): string { return 'ticket_states'; }` | +| `src/Endpoints/TicketPriorities/TicketPriorityRepository.php` | `protected function getListKey(): string { return 'ticket_priorities'; }` | +| `src/Endpoints/Tags/TagRepository.php` | `protected function getListKey(): string { return 'tags'; }` | +| `src/Endpoints/TextModules/TextModuleRepository.php` | `protected function getListKey(): string { return 'text_modules'; }` | + +### 3.6 NO CHANGES: `src/Core/Contracts/ClientInterface.php` + +Das Interface enthalt bereits nur `repo()` und `getHandler()` — es spiegelt bereits das Ziel-Design. + +### 3.7 NO CHANGES: `src/Core/RepositoryRegistry.php` + +Die Registry bleibt als "single source of truth" unverandert. Mit dem Wegfall von `aliasMap()` im Client gibt es keine Duplikation mehr. + +--- + +## 4. Test-Anderungen + +### 4.1 NEU: `test/Unit/ClientFactoryTest.php` + +Enthalt die aus `ZammadClientTest` verschobenen Tests: + +| Test-Methode | Beschreibung | +|---|---| +| `testWithClientUsesInjectedHttpClient` | Stellt sicher, dass ein injizierter PSR-18-Client verwendet wird | +| `testWithClientThrowsWhenFactoryNotStreamFactory` | Validiert den StreamFactory-Guard | + +### 4.2 MODIFY: `test/Unit/ZammadClientTest.php` + +**Bleiben (unverandert):** + +- `testRepoReturnsMemoizedRepositoryInstance` +- `testRepoThrowsForUnknownRepositoryClass` + +**Entfernt (weil getestete Funktion nicht mehr existiert):** + +- `testCallResolvesTicketRepository` — `__call` geloscht +- `testCallResolvesUserRepository` — `__call` geloscht +- `testCallMemoizesRepository` — `__call` geloscht +- `testCallThrowsForUnknownResource` — `__call` geloscht +- `testCallResolvesUnderscoreResources` — `__call` geloscht +- `testSetOnBehalfOfUserDelegatesToHandler` — Methode nicht mehr auf Client +- `testUnsetOnBehalfOfUserDelegatesToHandler` — Methode nicht mehr auf Client +- `testPerformOnBehalfOfExecutesCallbackAndResets` — Methode nicht mehr auf Client +- `testPerformOnBehalfOfResetsOnException` — Methode nicht mehr auf Client + +**Verschoben nach `ClientFactoryTest`:** + +- `testWithClientUsesInjectedHttpClient` +- `testWithClientThrowsWhenFactoryNotStreamFactory` + +Nur noch 2 Tests — der Client ist jetzt so dunn, dass wenig zu testen bleibt. Alle anderen Concerns werden an anderer Stelle getestet. + +### 4.3 MODIFY: `test/Unit/Core/RequestHandlerTest.php` + +Neue Tests fur `performOnBehalfOf()`: + +| Test-Methode | Beschreibung | +|---|---| +| `testPerformOnBehalfOfExecutesCallbackAndResets` | Setzt Impersonation, fuhrt Callback aus, stellt Zustand wieder her | +| `testPerformOnBehalfOfResetsOnException` | Stellt Zustand auch bei Exception im Callback wieder her | +| `testPerformOnBehalfOfReturnsCallbackValue` | Ruckgabewert des Callbacks wird durchgereicht | + +### 4.4 MODIFY: `test/Integration/Traits/CreatesZammadClient.php` + +```php +// Zeilen 30, 34: Vorher +return ZammadClient::withToken($url, $token); +return ZammadClient::withBasicAuth($url, $user, $pass); +// Nachher +return ClientFactory::withToken($url, $token); +return ClientFactory::withBasicAuth($url, $user, $pass); +``` + +Import hinzufugen: `use ZammadAPIClient\ClientFactory;` + +--- + +## 5. Bridge-Anderungen + +### 5.1 `src/Bridge/LaravelServiceProvider.php` + +```php +// Zeile 91: Vorher +return ZammadClient::withToken($url, $token); +// Nachher +return ClientFactory::withToken($url, $token); +``` + +Import hinzufugen: `use ZammadAPIClient\ClientFactory;` + +### 5.2 `src/Bridge/SymfonyBundle.php` + +```php +// Zeile 86: Vorher +$client = ZammadClient::withToken($url, $token); +// Nachher +$client = ClientFactory::withToken($url, $token); +``` + +Import hinzufugen: `use ZammadAPIClient\ClientFactory;` + +--- + +## 6. Dokumentation + +### 6.1 `examples/cookbook.php` + +```php +// Zeilen 29-30: Vorher +$client = $token !== '' + ? ZammadClient::withToken($url, $token) + : ZammadClient::withBasicAuth($url, $user, $pass); +// Nachher +$client = $token !== '' + ? ClientFactory::withToken($url, $token) + : ClientFactory::withBasicAuth($url, $user, $pass); +``` + +Import: `use ZammadAPIClient\ClientFactory;` + +Zeile 129-132 (Impersonation): +```php +// Vorher: +$client->performOnBehalfOf(1, function () use ($repo, $ticketId) { ... }); +// Nachher: +$client->getHandler()->performOnBehalfOf(1, function () use ($repo, $ticketId) { ... }); +``` + +### 6.2 `README.md` + +Ersetze `ZammadClient::withToken` → `ClientFactory::withToken` an allen 10 Stellen. Quick-Start-Beispiel: + +```php +use ZammadAPIClient\ClientFactory; +use ZammadAPIClient\Endpoints\Tickets\TicketDTO; +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; + +$client = ClientFactory::withToken('https://zammad.example', 'your-token'); +``` + +### 6.3 `docs/migration-v3.md` + +- Zeile 7, 44: `ZammadClient::withToken` → `ClientFactory::withToken` + +### 6.4 `docs/migration-v3-examples.md` + +- Zeilen 19, 38-40: `ZammadClient::with*` → `ClientFactory::with*` + +### 6.5 `docs/alternative-clients.md` + +- Zeile 72: `ZammadClient::withToken()` → `ClientFactory::withToken()` + +### 6.6 `CHANGELOG.md` + +Neue Eintrage unter `## [3.0.0] — unreleased`: + +```markdown +### Changed +- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` / `withClient()` → `ClientFactory::with*()` +- **Breaking:** `ZammadClient::performOnBehalfOf()` → `$client->getHandler()->performOnBehalfOf()` +- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` + +### Removed +- **Breaking:** Magic resource accessor (`$client->ticket()`) — ersatzlos entfernt. Nutze `$client->repo(TicketRepository::class)`. +- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` — nutze `$client->getHandler()->setOnBehalfOfUser()` +- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt +``` + +--- + +## 7. Ausfuhrungsreihenfolge + +| Schritt | Beschreibung | Dateien | Risiko | +|---|---|---|---| +| 1 | `ClientFactory` erstellen (neue Datei) | 1 new | Kein | +| 2 | `performOnBehalfOf` zu `RequestHandlerInterface` + `RequestHandler` hinzufugen | 2 modify | Gering | +| 3 | `ZammadClient` ausdunnen (factories, aliases, impersonation entfernen) | 1 modify | Mittel | +| 4 | `getListKey()` Default in `AbstractRepository` | 1 modify | Gering | +| 5 | `getListKey()` aus allen 10 Repos entfernen | 10 modify | Gering | +| 6 | `ClientFactoryTest` erstellen | 1 new | Kein | +| 7 | `ZammadClientTest` anpassen | 1 modify | Gering | +| 8 | `RequestHandlerTest` um `performOnBehalfOf`-Tests erganzen | 1 modify | Kein | +| 9 | Framework Bridges updaten | 2 modify | Gering | +| 10 | `CreatesZammadClient` Trait updaten | 1 modify | Gering | +| 11 | `cookbook.php` updaten | 1 modify | Kein | +| 12 | Dokumentation updaten | ~6 modify | Kein | +| 13 | `phpstan analyse src/ --level=max` | — | Prufung | +| 14 | `phpcs` | — | Prufung | +| 15 | `phpunit --testsuite=unit` | — | Prufung | +| 16 | `phpunit --testsuite=integration` | — | Prufung | + +--- + +## 8. Migration Notes fur Endnutzer + +```php +// ── Client-Erstellung ────────────────────────────────── +// VORHER: +$client = ZammadClient::withToken($url, $token); +$client = ZammadClient::withBasicAuth($url, $user, $pass); +$client = ZammadClient::withOAuth2($url, $token); +$client = ZammadClient::withClient($http, $factory, $url); + +// NACHHER: +$client = ClientFactory::withToken($url, $token); +$client = ClientFactory::withBasicAuth($url, $user, $pass); +$client = ClientFactory::withOAuth2($url, $token); +$client = ClientFactory::withClient($http, $factory, $url); + +// ── Impersonation ───────────────────────────────────── +// VORHER: +$client->setOnBehalfOfUser(1); +$client->unsetOnBehalfOfUser(); +$client->performOnBehalfOf(1, fn() => doSomething()); + +// NACHHER: +$client->getHandler()->setOnBehalfOfUser(1); +$client->getHandler()->setOnBehalfOfUser(null); +$client->getHandler()->performOnBehalfOf(1, fn() => doSomething()); + +// ── Magic Resource Accessor ─────────────────────────── +// VORHER: $client->ticket()->find(1); // deprecated +// NACHHER: $client->repo(TicketRepository::class)->find(1); // einziger Weg +``` + +**Keine Anderungen:** + +- `$client->repo(SomeRepository::class)` — unverandert +- `$client->getHandler()->get(...)` / `post(...)` / ... — unverandert +- Alle Repository-Klassen und DTOs — unverandert + +--- + +## 9. Phase 2 (nicht Teil dieses PRs) + +1. **Framework Bridges: Repositories im Container registrieren** — statt nur `ZammadClient`, jedes Repository als eigenen Service verfugbar machen. +2. **`RepositoryRegistry` per PHP-Attribut** — `#[Resource('tickets', TicketDTO::class)]` auf der Repo-Klasse statt zentraler Map. +3. **`getListKey()` ganzlich entfernen** — `$this->resourcePath` direkt verwenden. diff --git a/CHANGELOG.md b/CHANGELOG.md index be329b7..b152672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,37 @@ -## [2.3.0] - 2026-06-10 +# Changelog + +## [3.0.0] — unreleased + +### Added +- PSR-18 / PSR-17 compliant HTTP layer (`RequestHandler`, `RetryAfterMiddleware`) +- Typed DTOs for all 10 resources (`Ticket`, `User`, `Organization`, `Group`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) +- Repository pattern with generator-based pagination (`AbstractRepository`) +- `patch()` method for partial updates via `array` or `TicketUpdateDTO` +- Proper exception hierarchy: `AuthenticationException`, `NotFoundException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` +- `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods +- PSR-3 Logger injection +- OpenAPI schema validation in integration tests +- Framework bridges for Laravel and Symfony +- `TicketArticleType` enum for article channel types + +### Changed +- **Breaking:** PHP >= 8.1 required +- **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors +- **Breaking:** Array return values replaced by typed DTOs +- **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client + +### Deprecated +- Magic resource accessor (`$client->ticket()`) — triggers `E_USER_DEPRECATED`. Use `$client->repo(TicketRepository::class)` instead. Removed in v4.0. + +### Removed +- `ResourceType` constants — use typed repository methods +- `AbstractResource` 640-line monolith — replaced by individual repositories + +--- + +## Previous v2 releases + +### [2.3.0] - 2026-06-10 - Added [#78](https://github.com/zammad/zammad-api-client-php/pull/78) - Ticket linking support (Link resource). - Added [#59](https://github.com/zammad/zammad-api-client-php/issues/59) - Admin-scoped tag support. - Added [#126](https://github.com/zammad/zammad-api-client-php/pull/126) - Configurable test timeout via `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT` and `connection_options` support in HTTPClient. @@ -6,28 +39,8 @@ - Added [#43](https://github.com/zammad/zammad-api-client-php/issues/43) - `sort_by` and `order_by` parameters for `search()`. - Fixed [#77](https://github.com/zammad/zammad-api-client-php/issues/77) - `getID()` to handle null values and always return a string. -## [2.2.3] - 2026-06-08 +### [2.2.3] - 2026-06-08 - Fixed [#64](https://github.com/zammad/zammad-api-client-php/issues/64) - Use `From` header instead of deprecated `X-On-Behalf-Of`. -## [2.2.2] - 2026-04-27 +### [2.2.2] - 2026-04-27 - Fix PHP deprecation warnings. - -## [2.2.1] - 2025-09-10 -- Improve handling of lowercase HTTP response headers. - -## [2.2.0] - 2023-05-11 -- Switch to dual licensing under AGPL-3.0 or MIT licenses. - -## [2.1.0] - 2022-12-01 -- Added [#48](https://github.com/zammad/zammad-api-client-php/pull/48) - Allowing injection of custom client. - -## [2.0.5] - 2022-09-02 -- Fixed [#42](https://github.com/zammad/zammad-api-client-php/issues/42) - Call to undefined method GuzzleHttp\Exception\ConnectException::getResponse(). - -## [2.0.4] - 2022-07-29 -- Fixed [#49](https://github.com/zammad/zammad-api-client-php/issues/49) - "on_behalf_of_user" never gets initialized, which leads to conflicts. -- Fixed [#47](https://github.com/zammad/zammad-api-client-php/issues/47) - Make Guzzles HTTP connect_timeout configurable or set a decent default. -- Fixed [#46](https://github.com/zammad/zammad-api-client-php/issues/46) - Wrong documentation for SSL verification switch. - -## [2.0.3] - 2022-04-14 -- Updated tests to be compatible with new Zammad versions. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..208c7ca --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: help test test-integration coverage clean + +help: ## Show available make targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +test: ## Run phpcs, phpstan, phpunit (same as CI) + @echo "→ phpcs (PSR-12)" + @if [ -f vendor/bin/phpcs ] && [ -d src ]; then vendor/bin/phpcs --standard=PSR12 src/; else echo " Skip (src/ or vendor/ not present)"; fi + @echo "→ phpstan (level=max)" + @if [ -f vendor/bin/phpstan ] && [ -d src ]; then vendor/bin/phpstan analyse src/ --level=max; else echo " Skip (src/ or vendor/ not present)"; fi + @echo "→ phpunit (unit suite)" + @if [ -f vendor/bin/phpunit ] && [ -d test/Unit ]; then XDEBUG_MODE=off vendor/bin/phpunit --testsuite=unit --no-coverage; else echo " Skip (test/Unit/ or vendor/ not present)"; fi + +test-integration: ## Run integration tests (needs Zammad instance) + @echo "→ Integration test setup" + @echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL = $${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL:-not set}" + @echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN = $${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN:-not set}" + @echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME = $${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME:-not set}" + @echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD = $${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD:-not set}" + @if [ -z "$${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL:-}" ]; then \ + echo " → Set ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL to enable"; \ + echo " Example:"; \ + echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://localhost:3000 \\"; \ + echo " ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=xxx \\"; \ + echo " make test-integration"; \ + exit 1; \ + fi + @if [ -z "$${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN:-}" ] && [ -z "$${ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME:-}" ]; then \ + echo " → Set ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN or ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME for authentication"; \ + exit 1; \ + fi + @echo "→ Running integration tests..." + @if [ -f vendor/bin/phpunit ] && [ -d test/Integration ]; then \ + XDEBUG_MODE=off vendor/bin/phpunit --testsuite=integration --group=integration --display-skipped; \ + else \ + echo " Skip (test/Integration/ or vendor/ not present)"; \ + fi + +clean: ## Remove build artifacts + rm -rf build/ + rm -f .phpunit.cache + +coverage: ## Run unit tests with coverage report (build/coverage/html/) + @if [ -f vendor/bin/phpunit ] && [ -d test/Unit ]; then \ + php -d xdebug.mode=coverage vendor/bin/phpunit --testsuite=unit; \ + else \ + echo " Skip (test/Unit/ or vendor/ not present)"; \ + fi diff --git a/README.md b/README.md index 1c11f9a..d364b6f 100644 --- a/README.md +++ b/README.md @@ -1,362 +1,647 @@ -# Zammad API Client for PHP +# Zammad API Client for PHP (v3) -This client can be used to access the API of the open source helpdesk [Zammad](http://www.zammad.org) via PHP. +PSR-compliant PHP client for the [Zammad](https://zammad.com) REST API. PHP 8.1+. -## Zammad version support -This client supports Zammad 3.4.1 and newer. +## Quick Start -## Installation +> **Note:** v3 is under active development and not yet released as a stable Packagist version. Add the repository to your `composer.json`: +> ```json +> { +> "minimum-stability": "dev", +> "repositories": [{ "type": "vcs", "url": "https://github.com/zammad/zammad-api-client-php" }], +> "require": { "zammad/zammad-api-client-php": "dev-epic-79/pre-merge-develop" } +> } +> ``` -### Requirements -The API client needs [composer](https://getcomposer.org/). For installation have a look at its [documentation](https://getcomposer.org/download/). -Additionally, the API client needs PHP 7.2 or newer. - -### Integration into your project -Add the following to the "require" section of your project's composer.json file: -```json -"zammad/zammad-api-client-php": "^2.3" -``` - -### Installing the API client's dependencies -Fetch the API client's code and its dependencies by updating your project's dependencies with composer: -``` -$ composer update -``` - -Once installed, you have to include the generated autoload.php into your project's code: ```php -require_once dirname(__DIR__).'/vendor/autoload.php'; +use ZammadAPIClient\Endpoints\Tickets\TicketDTO; +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; +use ZammadAPIClient\ZammadClient; + +$client = ZammadClient::withToken('https://zammad.example', 'your-token'); + +// Fetch +$ticket = $client->repo(TicketRepository::class)->find(1); +echo $ticket->title; // typed property, IDE autocomplete + +// Create (customer_id is required on creation; article is optional) +// For production code, resolve priority_id/state_id by name via +// TicketPriorityRepository / TicketStateRepository. See examples/cookbook.php. +$created = $client->repo(TicketRepository::class)->create(new TicketDTO( + title: 'Hello from v3', + customer_id: 1, + group_id: 1, + priority_id: 2, + state_id: 1, + article: [ + 'subject' => 'Hello', + 'body' => 'Message body', + 'type' => 'note', + ], +)); + +// Partial update +$client->repo(TicketRepository::class)->patch($created->id, ['title' => 'Updated']); + +// Search +foreach ($client->repo(TicketRepository::class)->search('error') as $ticket) { + echo $ticket->title; +} ``` -## How to use the API client +## Getting started -### Example code -You can find example code within the directory `examples`. +### Standalone PHP app -### The Client object -Your starting point is the `Client` object: -```php -use ZammadAPIClient\Client; -$client = new Client([ - 'url' => 'https://myzammad.com', // URL to your Zammad installation - 'username' => 'myuser@myzammad.com', // Username to use for authentication - 'password' => 'mypassword', // Password to use for authentication - // 'timeout' => 15, // Sets timeout for requests, defaults to 5 seconds, 0: no timeout - // 'debug' => true, // Enables debug output - // 'verify' => true, // Enabled SSL verification. You can also give a path to a CA bundle file. Default is true. -]); -``` -Besides using a combination of `username` and `password`, you can alternatively give an `http_token` or an `oauth2_token`. -**Important:** You have to activate API access in Zammad. Should be active by default. +> **Note:** Until a stable release, use the same VCS repository setup as in Quick Start above. -### Fetching a single Resource object -To fetch a `Resource` object by ID, e. g. a ticket with ID 34, use the `Client` object: ```php -use ZammadAPIClient\ResourceType; -$ticket = $client->resource( ResourceType::TICKET )->get(34); -``` +setValue( 'title', 'My ticket title' ); -$title = $ticket->getValue('title'); -$all_values = $ticket->getValues(); +### Laravel + +```bash +# 1. Register the provider in config/app.php (skip if using auto-discovery, Laravel 5.5+) ``` +Add `ZammadAPIClient\Bridge\LaravelServiceProvider::class` to `config/app.php`. -Please note that the API client does not provide checks for nor does it know about the available fields of the `Resource` objects. If you set or get a value of a non-existing field or set an invalid value, Zammad will ignore it or return an error. +```bash +# 2. Publish the default config to config/zammad.php +php artisan vendor:publish --tag=zammad-config +``` +Then inject `ZammadClient` via the container. -So, how can you know which fields are available? Just fetch an existing `Resource` object and have a look at the returned fields. A fresh Zammad system always contains an object with ID 1 for every resource type. +### Symfony -Additionally you can have a look at the REST interface documentation of Zammad: +Register `ZammadAPIClient\Bridge\SymfonyBundle` in `config/bundles.php`. -[Introduction to the REST interface](https://docs.zammad.org/en/latest/api/intro.html) -* [Users](https://docs.zammad.org/en/latest/api/user.html) -* [Groups](https://docs.zammad.org/en/latest/api/group.html) -* [Organizations](https://docs.zammad.org/en/latest/api/organization.html) -* [Tickets](https://docs.zammad.org/en/latest/api/ticket.html) - * [Ticket articles](https://docs.zammad.org/en/latest/api/ticket/articles.html) - * [Ticket priorities](https://docs.zammad.org/en/latest/api/ticket-priority.html) - * [Ticket states](https://docs.zammad.org/en/latest/api/ticket-state.html) -* [Tags](https://docs.zammad.org/en/latest/api/tags.html) - * [Tag list](https://docs.zammad.org/en/latest/api/ticket/tags.html#administration-scope) - * [Linking Tickets](https://docs.zammad.org/en/latest/api/ticket/links.html) +## Authentication -#### Fetching a ticket's articles -If you already have a ticket object, you can easily fetch its articles: ```php -$ticket_articles = $ticket->getTicketArticles(); -``` +// Token — sends Authorization: Token token=your-token (Zammad personal access token) +ZammadClient::withToken($url, 'your-token'); -#### Fetching content of ticket article attachments -The content of ticket article attachments can be fetched with a call of `getAttachmentContent()` of the ticket article resource object: -```php -$attachment_content = $ticket_article->getAttachmentContent(23); -``` +// OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token) +ZammadClient::withOAuth2($url, 'your-oauth-token'); -In the above example 23 is the ID of the attachment. This ID can be found within the `attachments` array of the ticket article data. Usually you want to loop over this array to fetch the content of all attachments. +// Basic Auth — sends Authorization: Basic base64(user:pass) +ZammadClient::withBasicAuth($url, 'admin@example.com', 'test'); -`getAttachmentContent()` returns the attachment content as a string, ready to use. +// Options +ZammadClient::withToken($url, 'your-token', + new ConnectionConfig(verifySsl: false, maxRetries: 5), +); -### Updating Resource objects -If you fetched a `Resource` object and changed some values, you have to send your changes to Zammad. You do this with a simple call: -```php -$ticket->save(); +// Pass a PSR-3 Logger to log HTTP requests and retries +ZammadClient::withToken($url, 'your-token', + new ConnectionConfig(logger: $myLogger), +); ``` -`save()` will check it itself but if you somehow need to know if a `Resource` object has unsaved changes, you can check it with: -```php -if ( $ticket->isDirty() ) {...} -``` +| ConnectionConfig property | Type | Default | Description | +|---------------------------|------|---------|-------------| +| `maxRetries` | `int` | `3` | Number of retries on HTTP 429 before throwing `RateLimitException` | +| `verifySsl` | `bool` | `true` | Verify SSL certificate of the Zammad server | +| `timeout` | `int` | `30` | Total request timeout in seconds | +| `connectTimeout` | `int` | `10` | Connection timeout in seconds | +| `logger` | `?LoggerInterface` | `null` | PSR-3 Logger for HTTP request/retry logging | -Note: Some resource types don't support updating the values of certain fields. Please refer to the API documentation (see links above). +## Examples -### Creating Resource objects -To create a new `Resource` object, use the following code (example): -```php -use ZammadAPIClient\ResourceType; - -$ticket = $client->resource( ResourceType::TICKET ); -$ticket->setValue( 'title', 'My new ticket' ); -// ... -// Set additional values -// ... -$ticket->save(); // Will create a new ticket object in Zammad -``` +The primary example is [`examples/cookbook.php`](examples/cookbook.php) — nine runnable recipes covering tickets, stateful resources, pagination, error handling, impersonation, and search. Run it against any Zammad instance: -### Searching Resource objects -Some types of resources can be searched, pagination is available. -```php -use ZammadAPIClient\ResourceType; +```bash +ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL=http://your-zammad:3000 \ +ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN=your-token \ +php examples/cookbook.php +``` -// Fulltext search -$tickets = $client->resource( ResourceType::TICKET )->search('some text'); +> The env vars are named `...UNIT_TESTS...` for historical reasons. They are used by integration tests and the cookbook example. Unit tests (`make test`) need no env vars. -// Field specific search -$tickets = $client->resource( ResourceType::TICKET )->search('title:My Title'); +For user and organization CRUD examples, refer to the integration tests in [`test/Integration/`](test/Integration/) (`UserIntegrationTest.php`, `OrganizationIntegrationTest.php`). Side-by-side v2→v3 migration examples are in [`docs/migration-v3-examples.md`](docs/migration-v3-examples.md). -// Field specific search with more than one field -$tickets = $client->resource( ResourceType::TICKET )->search('title:My Title AND priority_id:1'); +## How to use -// Pagination: Page 1, 25 entries per page -$tickets = $client->resource( ResourceType::TICKET )->search( 'some text', 1, 25 ); -``` +This library offers three interaction styles. Choose based on your use case: -Note that there is a configurable server-side limit for the number of returned objects (e. g. 500). This limit also applies to the number of entries per page. If you call search() with 1000 entries per page and the server-side limit is set to 500, the server-side limit will be applied. +| Style | API | Best for | +|-------|-----|----------| +| **Repository + DTOs** (recommended) | `$repo->find()`, `create()`, `patch()`, `delete()` | Type-safe CRUD, IDE autocomplete, explicit intent. Use this by default. | +| **Stateful Resource** | `$repo->resource($id)->save()` / `destroy()` | Interactive editing — mutate properties step by step, only changes are sent. | +| **Raw HTTP** | `$client->getHandler()->get()`, `delete()`, etc. | Calling endpoints that have no dedicated repository. Escape hatch. | +| **Magic accessor** (deprecated) | `$client->ticket()->find(1)` | Triggers `E_USER_DEPRECATED`. Removed in v4. Migrate to `repo()`. | -A successful search (which might have zero results) returns an array of objects (or an empty array). If the result is the original caller object, there was an error (see error handling below). -Therefore, the code for searching should look like the following: +### Connecting ```php -use ZammadAPIClient\ResourceType; +use ZammadAPIClient\ZammadClient; -$tickets = $client->resource( ResourceType::TICKET )->search('some text'); -if ( !is_array($tickets) ) { - // Error handling - print $tickets->getError(); -} -else { - // Do something with $tickets array -} +// ZammadClient normalizes the URL — /api/v1 is appended automatically +$client = ZammadClient::withToken('https://zammad.example', 'your-token'); ``` -Note: You cannot use a `Resource` object that contains data (either via `get`, `search`, `all` or by setting values on a new object) to execute a search. Use a new `Resource` object instead. +### Fetching +```php +// Access via typed repository — autocomplete, type-safe +$ticket = $client->repo(TicketRepository::class)->find(1); +$user = $client->repo(UserRepository::class)->find(1); +$group = $client->repo(GroupRepository::class)->find(1); +``` -### Fetching 'all' Resource objects -For some types of resources, all available objects can be fetched, pagination is available. +### Accessing values ```php -use ZammadAPIClient\ResourceType; +$ticket = $client->repo(TicketRepository::class)->find(1); -// Fetch all tickets (keep in mind the server-side limit, see 'Searching Resource objects') -$tickets = $client->resource( ResourceType::TICKET )->all(); +echo $ticket->title; // Typed property, IDE autocomplete +echo $ticket->state_id; // ?int +echo $ticket->created_at; // ?DateTimeImmutable -// Fetch all tickets with pagination (keep in mind the server-side limit, see 'Searching Resource objects'), page 4, 50 entries per page -$tickets = $client->resource( ResourceType::TICKET )->all( 4, 50 ); +$data = $ticket->toArray(); // All values as array +$id = $ticket->id; // Server-assigned ID (null before create) ``` -A successful call of `all` (which might have zero results) returns an array of objects (or an empty array). If the result is the original caller object, there was an error (see error handling below). -Therefore, the code to use `all` should look like the following: + +### Creating ```php -use ZammadAPIClient\ResourceType; +$ticket = $client->repo(TicketRepository::class)->create(new TicketDTO( + title: 'My ticket', + customer_id: 1, + group_id: 1, + priority_id: 2, + state_id: 1, + article: [ + 'subject' => 'My ticket', + 'body' => 'First message', + 'type' => 'note', + ], +)); + +echo $ticket->id; // Server-assigned after creation +``` -$tickets = $client->resource( ResourceType::TICKET )->all( 4, 50 ); // pagination -if ( !is_array($tickets) ) { - // Error handling - print $tickets->getError(); -} -else { - // Do something with $tickets array -} +### Updating + +```php +$repo = $client->repo(TicketRepository::class); + +// Send a DTO — only non-null fields are transmitted +$repo->patch(1, new TicketDTO(title: 'New title', group_id: 1)); + +// Partial update via array — only supplied fields change +$repo->patch(1, ['title' => 'New title', 'state_id' => 3]); + +// Partial update via TicketUpdateDTO — only non-null fields sent +$repo->patch(1, new TicketUpdateDTO(title: 'New title')); ``` -Note: You cannot use a `Resource` object that contains data (either via `get`, `search`, `all` or by setting values on a new object) to execute `all`. Use a new `Resource` object instead. +### Stateful resource + +`$repo->resource($id)` returns a `Resource` wrapper — **not a DTO**. Properties are accessed and mutated via `__get`/`__set` magic (not typed properties), and changes are automatically tracked. `save()` sends only modified fields; `destroy()` sends DELETE. -### Deleting a Resource object -To be able to delete a `Resource` object that exists in Zammad, you must first fetch it from Zammad, either via `get`, `all` or `search`. -You can also delete a newly created `Resource` object that has not been sent to Zammad yet. But this should only rarely be necessary because you can simply create a new `Resource` object via the `Client` object. -To delete a `Resource` object, simply call `delete` on it: ```php -$ticket->delete(); +$repo = $client->repo(TicketRepository::class); + +$r = $repo->resource(1); // Returns Resource, fetches ticket #1 +echo $r->title; // Reads current title +$r->title = 'Changed'; // Tracks old → new +$r->state_id = 3; // Tracks old → new +$r->save(); // PUT {title, state_id} only +$r->destroy(); // DELETE ``` -This clears the object from all data and if possible deletes it in Zammad. The PHP object itself remains. You can reuse it for another `Resource` object or simply drop it. +Use this for interactive workflows where you read, modify, then write. For single-field changes, prefer `patch()`. -### Working with tags +### Choosing the right update method -#### Adding a tag to an object +`patch()` is the only update method. It accepts arrays, `TicketUpdateDTO`, or any DTO (via `toArray()`). Zammad uses HTTP PUT for all updates and merges the payload with the existing resource. Null values are excluded from all request bodies, so absent fields are never overwritten. -Zammad can assign tags to an object. Currently this is only supported for ticket objects. +| Signature | What it sends | Use case | +|-----------|---------------|----------| +| `patch($id, $array)` | Only the explicit array keys | Change one or two known fields. **Safest.** | +| `patch($id, $updateDto)` | Only the non-null DTO fields | IDE autocomplete on the mutable fields. | +| `patch($id, $dto)` | All non-null properties of the DTO (`toArray()`) | Replace multiple fields using a full DTO. | +| `resource($id)->save()` | Only actually changed fields (tracked) | Interactive editing with change tracking. | ```php -use ZammadAPIClient\ResourceType; - -// The third parameter 'Ticket' is the object type for which the ID will be given as first parameter. -$client->resource( ResourceType::TAG )->add( $ticket_id, 'tag 1', 'Ticket' ); +$repo = $client->repo(TicketRepository::class); + +// Array — simplest for ad-hoc changes +$repo->patch(1, ['title' => 'New title', 'state_id' => 3]); + +// TicketUpdateDTO — type-safe, IDE-friendly +$repo->patch(1, new TicketUpdateDTO(title: 'New title')); + +// Full DTO — send a modified TicketDTO +$ticket = $repo->find(1); +$repo->patch(1, new TicketDTO( + title: $ticket->title, + group_id: 2, // changed from original +)); + +// Stateful resource — tracks property changes +$r = $repo->resource(1); +$r->title = 'Changed'; +$r->state_id = 3; +$r->save(); // Sends only {title, state_id} ``` +### Deleting -#### Remove a tag from an object +All repositories expose a `delete()` method. Repositories implementing `DeletableInterface` perform the actual API call. Other repositories throw a `BadMethodCallException` — catchable, unlike a fatal error. -```php -use ZammadAPIClient\ResourceType; +| Repository | `delete()` | Notes | +|-----------|:----------:|-------| +| `TicketRepository` | ✓ | | +| `UserRepository` | ✓ | | +| `GroupRepository` | ✓ | | +| `OrganizationRepository` | ✓ | | +| `TextModuleRepository` | ✓ | | +| `TagRepository` | exception | Throws `BadMethodCallException`; use `add()` / `remove()` | +| `LinkRepository` | exception | Throws `BadMethodCallException`; use `add()` / `remove()` | +| `TicketArticleRepository` | exception | Zammad API does not allow article deletion | +| `TicketStateRepository` | exception | System resource, read-only | +| `TicketPriorityRepository` | exception | System resource, read-only | -$client->resource( ResourceType::TAG )->remove( $ticket_id, 'tag 1', 'Ticket' ); +```php +$client->repo(TicketRepository::class)->delete(1); +$client->repo(UserRepository::class)->delete(1); ``` -#### Getting all tags assigned to an object +### Searching ```php -use ZammadAPIClient\ResourceType; +$repo = $client->repo(TicketRepository::class); -// The second parameter 'Ticket' is the object type for which the ID will be given as first parameter. -$tag = $client->resource( ResourceType::TAG )->get( $ticket_id, 'Ticket' ); +// Full-text search — returns a lazy Generator (page by page) +// Use foreach directly; count()/array access requires iterator_to_array() +foreach ($repo->search('some text') as $ticket) { + echo $ticket->title; +} + +// Field-specific search +foreach ($repo->search('title:Error AND priority_id:1') as $ticket) { + echo $ticket->number; +} -// [ 'tag 1', 'tag 2' ] -$tags = $tag->getValue('tags') +// PaginatedList — count(), totalCount(), page navigation +$list = $repo->searchList('error', ['per_page' => 25]); +echo $list->totalCount(); +$list->page(2); +$list->each(function ($t) { echo $t->title; }); ``` -#### Search for Tags +### Listing all ```php -use ZammadAPIClient\ResourceType; +$repo = $client->repo(TicketRepository::class); -$tags = $client->resource( ResourceType::TAG )->search('my tag'); -``` -### Linking Tickets +// Lazy Generator — pages fetched on demand, memory-efficient +// Use in foreach; need count? Use list() for PaginatedList instead. +foreach ($repo->all() as $ticket) { + echo $ticket->title; +} -#### Linking two Tickets +// PaginatedList — count(), page navigation, each() callback +$list = $repo->list(); +echo $list->count(); // Items on current page +$list->page(2); // Jump to page 2 +$list->pageNext(); // Next page +$list->each(function ($t) { echo $t->title; }); +``` -Zammad can link two or more Ticket objects. Allowed Link Types are `normal`, `parent` or `child`. +### Ticket articles ```php -use ZammadAPIClient\ResourceType; +$repo = $client->repo(TicketArticleRepository::class); -// First parameter $sourceTicket is the Ticket that should be linked -// Second parameter $targetTicket is the Ticket that $sourceTicket should be linked to -// Third parameter is the LinkType the $sourceTicket will be linked to $targetTicket with. -$client->resource( ResourceType::LINK )->add( $sourceTicket, $targetTicket, 'normal' ); -``` +// All articles for a ticket (paginated) +foreach ($repo->getForTicket(1) as $article) { + echo $article->body; +} -### Object import +// Download raw attachment content +$binary = $repo->getAttachmentContent( + ticketId: 1, articleId: 5, attachmentId: 23, +); +``` -Besides the usual methods available for objects, there is also a method available to import these via CSV. Example for text module CSV import: +### Tags ```php -use ZammadAPIClient\ResourceType; +$repo = $client->repo(TagRepository::class); -$text_modules_csv_string = file_get_contents('text_modules.csv'); +$repo->add('Ticket', $ticketId, 'urgent'); +$repo->remove('Ticket', $ticketId, 'urgent'); -$client->resource( ResourceType::TEXT_MODULE )->import($text_modules_csv_string); +foreach ($repo->all(['object' => 'Ticket', 'o_id' => $ticketId]) as $tag) { + echo $tag->value; +} +$results = $repo->tagSearch('urg'); // Autocomplete ``` -See __Available resource types and their access methods__ below for resource types that support CSV import. +### CSV import -### Handling Zammad errors -When you access Zammad, you **always** will get a `Resource` object (or an array of such objects) in return, regardless if Zammad returned data or executed your request. In case of errors (e. g. that above ticket with ID 34 does not exist in Zammad), you will get a `Resource` object with a set error which can be checked with the following code: ```php -if ( $ticket->hasError() ) { - print $ticket->getError(); -} +$csv = file_get_contents('users.csv'); +$result = $client->repo(UserRepository::class)->import($csv); // Returns import summary array +$result = $client->repo(OrganizationRepository::class)->import($csv); // Returns import summary array +$client->repo(TextModuleRepository::class)->import($csv); // Returns import summary array ``` -If you additionally need more detailed information about connection/request errors, you can access the `Response` object of the `Client` object. It holds the response of the last request that was made. -```php -$last_response = $client->getLastResponse(); -``` -With this object, you can e. g. get the HTTP status code and the body of the last response. +All `import()` methods return an `array` — the Zammad API response containing import statistics (rows processed, skipped, errors). +CSV format follows Zammad's import specification (header row with field names matching API field names). + +## Error Handling + +All errors are typed exceptions: -### Executing an API call on behalf of another user -If you want Zammad to execute an API call on behalf of another user than the one you used for authentication, use the following code before executing the API call(s): -```php -$client->setOnBehalfOfUser('myuser'); -``` -This sets the `From` HTTP header. Any API call after above code will use this setting. If you want to return to using the user you used for authentication, call: ```php -$client->unsetOnBehalfOfUser(); +use ZammadAPIClient\Exceptions\{ + AuthenticationException, + ForbiddenException, + NotFoundException, + ValidationException, + RateLimitException, + ServerErrorException, + NetworkException, +}; + +try { + $client->repo(TicketRepository::class)->find(999999); +} catch (NotFoundException $e) { + echo $e->getMessage(); // "Resource not found: tickets/999999" +} catch (ValidationException $e) { + print_r($e->errors); // Per-field validation messages +} catch (AuthenticationException $e) { + // Invalid credentials (401) +} catch (ForbiddenException $e) { + // Valid credentials but insufficient permissions (403) +} catch (RateLimitException $e) { + echo $e->retryAfterSeconds; // Auto-retried, thrown on exhaustion (429) +} catch (ServerErrorException $e) { + // Server error (5xx) +} catch (NetworkException $e) { + // DNS, timeout, connection refused +} ``` -Using this setting will be ignored by Zammad before version 2.4. +| Exception | HTTP | Auto-retry | Properties | +|-----------|------|:----------:|------------| +| `AuthenticationException` | 401 | no | `$e->getMessage()` | +| `ForbiddenException` | 403 | no | `$e->getMessage()` | +| `NotFoundException` | 404 | no | `$e->getMessage()` | +| `ValidationException` | 422 | no | `$e->errors` (array, per-field) | +| `RateLimitException` | 429 | yes | `$e->retryAfterSeconds` | +| `ServerErrorException` | 5xx | no | `$e->getMessage()` | +| `NetworkException` | — | no | DNS, timeout, connection refused | + +## Data Transfer Objects (DTOs) + +Each repository returns typed DTOs. Below are the fields for each DTO. Fields marked **yes** have no default and are required in the constructor. Fields marked **creation** are nullable in the type signature but required by the Zammad API when creating a new resource — omitting them will result in a `ValidationException` (422). + +The `id` field is available both as a property (`$dto->id`) and a convenience method (`$dto->id()`). Both return the same server-assigned ID; prefer the property for readability. + +### TicketDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `title` | `string` | yes | Subject line of the ticket | +| `group_id` | `?int` | — | Group responsible for the ticket | +| `priority_id` | `?int` | — | References TicketPriority; resolve by name via `TicketPriorityRepository` | +| `state_id` | `?int` | — | References TicketState; resolve by name via `TicketStateRepository` | +| `organization_id` | `?int` | — | Derived from customer's organization | +| `customer_id` | `?int` | **creation** | End-user who submitted the ticket (Zammad requires this on create) | +| `owner_id` | `?int` | — | Agent assigned to the ticket | +| `number` | `?string` | — | Human-readable ticket number (read-only) | +| `id` | `?int` | — | Server-assigned (null before creation) | +| `pending_time` | `?DateTimeImmutable` | — | ISO 8601 datetime for pending states | +| `article` | `?array` | — | Optional initial article. Array shape: `{ subject: string, body: string, type: string, internal?: bool, content_type?: string, ... }`. Use `TicketArticleType` enum constants (e.g. `TicketArticleType::Note->value`) for type-safe `type` values. | +| `created_at` | `?DateTimeImmutable` | — | Server-assigned (read-only) | +| `updated_at` | `?DateTimeImmutable` | — | Server-assigned (read-only) | +| `customFields` | `array` | — | Zammad custom fields (`string => mixed`). Named camelCase per Zammad API convention. + +### TicketUpdateDTO + +Used with `patch()` for partial ticket updates. Only non-null fields are sent to the API. + +| Field | Type | Notes | +|-------|------|-------| +| `title` | `?string` | | +| `state_id` | `?int` | | +| `priority_id` | `?int` | | +| `group_id` | `?int` | | +| `owner_id` | `?int` | | +| `customer_id` | `?int` | | +| `note` | `?string` | Adds an internal note (article type 'note') on update | +| `pending_time` | `?DateTimeImmutable` | ISO 8601 datetime for pending states | -## Available resource types and their access methods - -To be able to use the 'short form' for the resource type, add a ```php -use ZammadAPIClient\ResourceType; +// Example: reassign ticket and leave an internal note +$client->repo(TicketRepository::class)->patch(42, new TicketUpdateDTO( + owner_id: 7, + note: 'Reassigned from support queue.', +)); ``` -to your code. You then can reference the resource type like +### UserDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `login` | `?string` | — | Unique username | +| `email` | `?string` | — | Primary email address | +| `firstname` | `?string` | — | | +| `lastname` | `?string` | — | | +| `phone` | `?string` | — | | +| `organization_id` | `?int` | — | Primary organization | +| `organization_ids` | `?array` | — | Array of secondary organization IDs | +| `role_ids` | `?array` | — | Array of role IDs (e.g. `[2]` for Agent) | +| `active` | `?bool` | — | Whether the user account is active | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | +| `customFields` | `array` | — | | + +### OrganizationDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `name` | `string` | yes | Display name | +| `note` | `?string` | — | | +| `active` | `?bool` | — | | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | +| `customFields` | `array` | — | | + +### GroupDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `name` | `string` | yes | Display name | +| `note` | `?string` | — | | +| `active` | `?bool` | — | | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | +| `customFields` | `array` | — | | + +### TicketArticleDTO + +| Field | Type | Notes | +|-------|------|-------| +| `ticket_id` | `?int` | Parent ticket | +| `type` | `?string` | Channel type: `TicketArticleType::Note->value` (`'note'`), `Email` (`'email'`), `Phone` (`'phone'`), `Sms` (`'sms'`), `Web` (`'web'`) | +| `body` | `?string` | Message content | +| `content_type` | `?string` | MIME type: `'text/plain'` or `'text/html'` | +| `subject` | `?string` | Subject line for email-type articles | +| `from` | `?string` | Sender address/name | +| `to` | `?string` | Recipient address | +| `cc` | `?string` | CC address | +| `internal` | `?bool` | Whether it's an internal note (hidden from customer) | +| `in_reply_to` | `?string` | Message-ID for threading | +| `reply_to` | `?string` | Reply-To address | +| `message_id` | `?string` | Message-ID of this article | +| `origin_by_id` | `?int` | User who created the article (for impersonation) | +| `sender` | `?string` | Read-only: `'Customer'`, `'Agent'`, etc. | +| `type_id` | `?int` | Read-only | +| `sender_id` | `?int` | Read-only | +| `created_by_id` | `?int` | Read-only | +| `updated_by_id` | `?int` | Read-only | +| `created_by` | `?string` | Read-only | +| `updated_by` | `?string` | Read-only | +| `time_unit` | `?float` | Time accounting (minutes) | +| `attachments` | `?array` | Array of `{filename, data (base64), mime-type?}` | +| `id` | `?int` | Server-assigned | +| `created_at` | `?DateTimeImmutable` | Read-only | +| `updated_at` | `?DateTimeImmutable` | Read-only | + +### TicketStateDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `name` | `string` | yes | Display label (e.g. `'open'`, `'closed'`) | +| `state_type_id` | `?int` | — | Determines Zammad's automation behaviour | +| `note` | `?string` | — | | +| `active` | `?bool` | — | | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | + +### TicketPriorityDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `name` | `string` | yes | Display label (e.g. `'2 normal'`, `'3 high'`) | +| `note` | `?string` | — | | +| `active` | `?bool` | — | | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | + +### TextModuleDTO + +| Field | Type | Required | Notes | +|-------|------|:--------:|-------| +| `name` | `string` | yes | Display name | +| `keywords` | `?string` | — | Space-separated search keywords | +| `content` | `?string` | — | Template body with optional `#{...}` variables | +| `note` | `?string` | — | | +| `active` | `?bool` | — | | +| `id` | `?int` | — | Server-assigned | +| `created_at` | `?DateTimeImmutable` | — | Read-only | +| `updated_at` | `?DateTimeImmutable` | — | Read-only | + +### TagDTO + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `?int` | Tag-assignment ID | +| `object` | `?string` | Object class name (e.g. `'Ticket'`) | +| `o_id` | `?int` | Numeric ID of the tagged object | +| `value` | `?string` | Tag string (e.g. `'urgent'`, `'bug'`) | + +### LinkDTO + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `?int` | Server-assigned | +| `link_type_id` | `?int` | | +| `link_type` | `?string` | `'normal'`, `'parent'`, or `'child'` | +| `link_object_source` | `?string` | Source object type | +| `link_object_source_value` | `?int` | Source object ID | +| `link_object_target` | `?string` | Target object type | +| `link_object_target_value` | `?int` | Target object ID | +| `created_at` | `?DateTimeImmutable` | Read-only | +| `updated_at` | `?DateTimeImmutable` | Read-only | + +## Impersonation + ```php -$client->resource( ResourceType::TICKET ); -``` +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; -|Resource type|get|all|search|save|delete|add|remove|import| -|-------------|:-:|:-:|:----:|:--:|:----:|:-:|:----:|:----:| -| TICKET|✔|✔|✔|✔|✔|–|–|–| -| TICKET_ARTICLE|✔|–|✔|✔|✔|–|–|–| -| TICKET_STATE|✔|✔|–|✔|✔|–|–|–| -| TICKET_PRIORITY|✔|✔|–|✔|✔|–|–|–| -| TEXT_MODULE|✔|✔|–|✔|✔|–|–|✔| -| ORGANIZATION|✔|✔|✔|✔|✔|–|–|✔| -| GROUP|✔|✔|–|✔|✔|–|–|–| -| USER|✔|✔|✔|✔|✔|–|–|✔| -| TAG|✔|✔|✔|✔|✔|✔|✔|–| -| LINK|✔|–|–|–|–|✔|✔|–| +// Temporary — auto-cleanup via finally +// Accepts user ID (int), login, or email (string) +$client->performOnBehalfOf(1, fn() => $client->repo(TicketRepository::class)->find(42)); +$client->performOnBehalfOf('agent@example.com', fn() => $client->repo(TicketRepository::class)->find(42)); +// Persistent — same parameter types +$client->setOnBehalfOfUser(1); +// ... all subsequent requests act as user #1 ... +$client->unsetOnBehalfOfUser(); +``` -## Publishing +## Development -1. Add release to [CHANGELOG.md](CHANGELOG.md) -2. Commit, tag and push. -3. As a logged-in user, use the "update" button on the [packagist.org page of zammad-api-client-php](https://packagist.org/packages/zammad/zammad-api-client-php) to create the new version automatically from the git tag. +```bash +composer install +make test # Unit tests (<1s, no Docker, no Zammad needed) +make test-integration # Integration tests (requires a running Zammad instance) +``` -## Contributing +### Unit tests -Bug reports and pull requests are welcome on [GitHub](https://github.com/zammad/zammad-api-client-php). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +No environment variables needed. Unit tests mock the HTTP layer and run fully isolated. -### Running tests +### Integration tests -Tests require a running Zammad instance with API access enabled. Set the following environment variables: +These require a running Zammad instance and authentication credentials: | Variable | Required | Default | Description | |---|---|---|---| -| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL` | Yes | — | URL to Zammad (e.g. `http://localhost:3000/`) | -| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME` | Yes | — | Username for authentication | -| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD` | Yes | — | Password for authentication | -| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT` | No | `30` | Request timeout in seconds | - -**Note:** Only username/password authentication is supported for tests. - -```bash -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL="http://localhost:3000/" \ -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME="admin@example.com" \ -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD="test" \ -ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TIMEOUT=120 \ -vendor/bin/phpunit -``` +| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_URL` | Yes | `http://localhost:3000` | Zammad server URL (without /api/v1) | +| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN` | No | — | Token authentication (preferred) | +| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_USERNAME` | No* | — | Username for basic auth | +| `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_PASSWORD` | No* | — | Password for basic auth | + +\* Either `ZAMMAD_PHP_API_CLIENT_UNIT_TESTS_TOKEN` or `USERNAME`+`PASSWORD` must be set. + +## Migration from v2 + +See [docs/migration-v3-examples.md](docs/migration-v3-examples.md) for side-by-side code examples. +v2 reference documentation is preserved in [docs/v2-reference.md](docs/v2-reference.md). + +| v2 | v3 | +|----|----| +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, ...)` | +| `$client->resource(TICKET)->get(1)` | `$client->repo(TicketRepository::class)->find(1)` | +| `$ticket->getValue('title')` | `$ticket->title` | +| `$ticket->getValues()` | `$ticket->toArray()` | +| `$ticket->setValue('title', 'x'); $ticket->save()` | `$client->repo(TicketRepository::class)->patch(1, ['title' => 'x'])` | +| `if ($ticket->hasError()) { $ticket->getError(); }` | `catch (NotFoundException $e) { $e->getMessage(); }` | +| `$client->resource(TICKET)->search('term')` | `$client->repo(TicketRepository::class)->search('term')` | +| `$client->resource(TICKET)->all()` | `$client->repo(TicketRepository::class)->all()` | +| `$ticket->delete()` | `$client->repo(TicketRepository::class)->delete($id)` | +| `$client->resource(TAG)->add($ticketId, 'tag', 'Ticket')` | `$client->repo(TagRepository::class)->add('Ticket', $ticketId, 'tag')` *(order changed)* | + +## License + +AGPL-3.0 or MIT. diff --git a/bin/install-git-hooks b/bin/install-git-hooks new file mode 100755 index 0000000..69545cd --- /dev/null +++ b/bin/install-git-hooks @@ -0,0 +1,15 @@ +#!/usr/bin/env php +=7.2", - "guzzlehttp/guzzle": "^7" + "php": ">=8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "guzzlehttp/guzzle": "^7.10", + "guzzlehttp/psr7": "^2.7", + "psr/log": "^3.0" }, "require-dev": { - "phpunit/phpunit": ">=5.7 <9" + "phpunit/phpunit": "^11.0", + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^1.12", + "league/openapi-psr7-validator": "^0.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "suggest": { + "symfony/http-client": "Alternative PSR-18 transport", + "symfony/serializer": "Alternative DTO hydration (replaces manual fromArray)", + "symfony/cache": "PSR-6/16 cache for response caching", + "illuminate/support": "Laravel ServiceProvider integration", + "symfony/dependency-injection": "Symfony DI container integration", + "symfony/http-kernel": "Symfony Bundle integration" }, "autoload": { - "psr-4": {"ZammadAPIClient\\": "src/"} - } + "psr-4": { "ZammadAPIClient\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "ZammadAPIClient\\Tests\\": "test/" } + }, + "scripts": { + "cs": "phpcs", + "cs:fix": "phpcbf", + "stan": "phpstan analyse src/ --level=max", + "test": "XDEBUG_MODE=off php vendor/bin/phpunit --testsuite=unit --no-coverage", + "test:coverage": "php -d xdebug.mode=coverage vendor/bin/phpunit --testsuite=unit", + "test:integration": "phpunit --testsuite=integration --group=integration", + "post-install-cmd": [ + "php bin/install-git-hooks" + ], + "post-update-cmd": [ + "php bin/install-git-hooks" + ] + }, + "extra": { + "laravel": { + "providers": ["ZammadAPIClient\\Bridge\\LaravelServiceProvider"] + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/config/zammad.php b/config/zammad.php new file mode 100644 index 0000000..f70cd72 --- /dev/null +++ b/config/zammad.php @@ -0,0 +1,17 @@ + env('ZAMMAD_URL', 'http://127.0.0.1:8098'), + + /** API Authentication Token + * Zammad personal access token. Create one in your Zammad profile under + * "Token Access". See https://docs.zammad.org/en/latest/api/intro.html + */ + 'token' => env('ZAMMAD_TOKEN', ''), + +]; diff --git a/docs/alternative-clients.md b/docs/alternative-clients.md new file mode 100644 index 0000000..186ddfc --- /dev/null +++ b/docs/alternative-clients.md @@ -0,0 +1,144 @@ +# Using an Alternative PSR-18 HTTP Client + +The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. The factory methods (`withToken()`, `withOAuth2()`, `withBasicAuth()`) use Guzzle by default for convenience, but you can inject any PSR-18 client directly. + +Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. + +## Symfony HttpClient + +Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7` for PSR-17 factories). + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use GuzzleHttp\Psr7\HttpFactory; +use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\ZammadClient; + +$handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => 30, + 'max_duration' => 30, + 'verify_peer' => true, + 'verify_host' => true, + ])), + new HttpFactory(), + 'https://zammad.example', + maxRetries: 3, +); + +$client = new ZammadClient($handler); +``` + +### With Authentication Headers + +Symfony's `Psr18Client` does not accept global default headers. Instead, configure them on the `RequestHandler` by passing them via `$options` on each `request()` call, or use a PSR-18 middleware to inject the `Authorization` header. + +**Option A — Inject headers via a PSR-18 decorator middleware:** + +```php +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; + +final class AuthMiddleware implements ClientInterface +{ + public function __construct( + private ClientInterface $next, + private string $token, + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->next->sendRequest( + $request->withHeader('Authorization', "Token token={$this->token}") + ); + } +} + +$http = new AuthMiddleware( + new Psr18Client(HttpClient::create(['timeout' => 30])), + 'your-zammad-api-token', +); + +// Rate-limit retry is handled automatically by RequestHandler +$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example'); +``` + +**Option B — Build the Guzzle client with auth headers, then swap to Symfony later:** + +If you're migrating away from Guzzle, start with `ZammadClient::withToken()` and refactor step by step. + +## Laravel + +The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony instead, override the binding in your `AppServiceProvider`: + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use GuzzleHttp\Psr7\HttpFactory; +use ZammadAPIClient\Core\RequestHandler; +use ZammadAPIClient\ZammadClient; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app->singleton(ZammadClient::class, function () { + $handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => config('zammad.timeout', 30), + ])), + new HttpFactory(), + config('zammad.url'), + maxRetries: 3, + ); + return new ZammadClient($handler); + }); + } +} +``` + +## Symfony + +The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition: + +```yaml +# config/services.yaml +services: + ZammadAPIClient\ZammadClient: + factory: ['ZammadAPIClient\ZammadClient', 'withToken'] + arguments: + $url: '%env(ZAMMAD_URL)%' + $token: '%env(ZAMMAD_TOKEN)%' + + # Or with a custom PSR-18 stack: + app.zammad_client: + class: ZammadAPIClient\ZammadClient + arguments: + - '@app.zammad_request_handler' +``` + +## Custom PSR-18 Middleware Stack + +`RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). + +```php +$http = new LoggingMiddleware( + new MetricsMiddleware( + new Psr18Client(), + ), +); + +// Rate-limit retry is applied internally by RequestHandler +$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example', maxRetries: 5); +``` + +## Important Security Notes + +- **Configure timeouts**: Always set `timeout` and connection timeout on your PSR-18 client. The factory methods default to 30s/10s respectively. +- **Verify TLS**: Do not disable TLS verification in production. The factory methods default to `verifySsl = true`. +- **Redirects**: The factory methods disable redirect following (`allow_redirects = false`). If using your own PSR-18 client, configure redirect behavior carefully — following redirects may leak the `Authorization` header to unintended hosts. +- **Debug logging**: Use a PSR-3 logger via `ConnectionConfig`. Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/migration-v3-examples.md b/docs/migration-v3-examples.md new file mode 100644 index 0000000..d3534ba --- /dev/null +++ b/docs/migration-v3-examples.md @@ -0,0 +1,386 @@ +# Migration from v2 to v3 — Side-by-Side Examples + +## Connecting + +**v2:** +```php +use ZammadAPIClient\Client; + +$client = new Client([ + 'url' => 'https://zammad.example/api/v1', + 'http_token' => 'your-token', +]); +``` + +**v3:** +```php +use ZammadAPIClient\ZammadClient; + +$client = ZammadClient::withToken( + 'https://zammad.example', + 'your-token', +); +``` + +--- + +## Authentication Methods + +**v2:** +```php +new Client(['url' => $url, 'http_token' => '...']); // Token +new Client(['url' => $url, 'oauth2_token' => '...']); // OAuth2 +new Client(['url' => $url, 'username' => '...', 'password' => '...']); // Basic +``` + +**v3:** +```php +ZammadClient::withToken($url, '...'); // Token +ZammadClient::withOAuth2($url, '...'); // OAuth2 +ZammadClient::withBasicAuth($url, '...', '...'); // Basic +``` + +--- + +## Fetching by ID + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(1); +$title = $ticket->getValue('title'); +``` + +**v3:** +```php +$ticket = $client->repo(TicketRepository::class)->find(1); +$title = $ticket->title; +``` + +--- + +## Accessing Values + +**v2:** +```php +$title = $ticket->getValue('title'); +$data = $ticket->getValues(); +``` + +**v3:** +```php +$title = $ticket->title; +$data = $ticket->toArray(); +``` + +--- + +## Creating + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET); +$ticket->setValue('title', 'My ticket'); +$ticket->setValue('group_id', 1); +$ticket->save(); +``` + +**v3:** +```php +$ticket = $client->repo(TicketRepository::class)->create(new TicketDTO( + title: 'My ticket', + group_id: 1, +)); +``` + +--- + +## Updating + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(1); +$ticket->setValue('title', 'Updated'); +$ticket->setValue('state_id', 3); +$ticket->save(); +``` + +**v3:** +```php +// Via array +$client->repo(TicketRepository::class)->patch(1, ['title' => 'Updated']); + +// Via TicketUpdateDTO +$client->repo(TicketRepository::class)->patch(1, new TicketUpdateDTO( + title: 'Updated', +)); + +// Via TicketDTO (same behavior — all non-null fields are sent) +$client->repo(TicketRepository::class)->patch(1, new TicketDTO( + title: 'Updated', + state_id: 3, +)); +``` + +--- + +## Stateful Resource (Change Tracking) + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(1); +$ticket->setValue('title', 'New'); +$ticket->setValue('state_id', 3); +$ticket->save(); // Sends all values back to Zammad +``` + +**v3:** +```php +$resource = $client->repo(TicketRepository::class)->resource(1); +$resource->title = 'New'; +$resource->state_id = 3; +$resource->save(); // Sends only {title, state_id} + +// Delete +$resource->destroy(); +``` + +--- + +## Deleting + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(1); +$ticket->delete(); +``` + +**v3:** +```php +$client->repo(TicketRepository::class)->delete(1); +``` + +--- + +## Searching + +**v2:** +```php +$tickets = $client->resource(ResourceType::TICKET)->search('some text'); +if (!is_array($tickets)) { + print $tickets->getError(); +} else { + foreach ($tickets as $t) { + echo $t->getValue('title'); + } +} +``` + +**v3:** +```php +try { + foreach ($client->repo(TicketRepository::class)->search('some text') as $ticket) { + echo $ticket->title; + } +} catch (NotFoundException $e) { + echo $e->getMessage(); +} +``` + +--- + +## Listing All + +**v2:** +```php +$tickets = $client->resource(ResourceType::TICKET)->all(); +``` + +**v3:** +```php +foreach ($client->repo(TicketRepository::class)->all() as $ticket) { + echo $ticket->title; +} +``` + +--- + +## Pagination + +**v2:** +```php +$page = $client->resource(ResourceType::TICKET)->all(1, 25); +``` + +**v3:** +```php +$list = $client->repo(TicketRepository::class)->list(['per_page' => 25]); +$list->page(1); // First page +$list->page(2); // Second page +$list->pageNext(); // Next page +$list->pagePrev(); // Previous page +$list->each(function ($t) { echo $t->title; }); +``` + +--- + +## Error Handling + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(999); +if ($ticket->hasError()) { + echo $ticket->getError(); +} +``` + +**v3:** +```php +try { + $ticket = $client->repo(TicketRepository::class)->find(999); +} catch (NotFoundException $e) { + echo $e->getMessage(); // "Resource not found: tickets/999" +} catch (ValidationException $e) { + print_r($e->errors); // Per-field validation messages +} catch (AuthenticationException $e) { + // Invalid credentials +} catch (RateLimitException $e) { + echo $e->retryAfterSeconds; // Auto-retried, only thrown on exhaustion +} catch (ServerErrorException $e) { + // 5xx server error +} catch (NetworkException $e) { + // DNS, timeout, connection refused +} +``` + +--- + +## Ticket Articles + +**v2:** +```php +$ticket = $client->resource(ResourceType::TICKET)->get(1); +$articles = $ticket->getTicketArticles(); +``` + +**v3:** +```php +foreach ($client->repo(TicketArticleRepository::class)->getForTicket(1) as $article) { + echo $article->body; +} +``` + +--- + +## Ticket Article Attachments + +**v2:** +```php +$ticket_article = $client->resource(ResourceType::TICKET_ARTICLE); +$content = $ticket_article->getAttachmentContent(23); +``` + +**v3:** +```php +$content = $client->repo(TicketArticleRepository::class)->getAttachmentContent( + ticketId: 1, + articleId: 5, + attachmentId: 23, +); +``` + +--- + +## Tags + +**v2:** +```php +$client->resource(ResourceType::TAG)->add($ticketId, 'urgent', 'Ticket'); +$client->resource(ResourceType::TAG)->remove($ticketId, 'urgent', 'Ticket'); + +$tag = $client->resource(ResourceType::TAG)->get($ticketId, 'Ticket'); +$tags = $tag->getValue('tags'); +``` + +**v3:** +```php +$client->repo(TagRepository::class)->add('Ticket', $ticketId, 'urgent'); +$client->repo(TagRepository::class)->remove('Ticket', $ticketId, 'urgent'); + +foreach ($client->repo(TagRepository::class)->all(['object' => 'Ticket', 'o_id' => $ticketId]) as $tag) { + echo $tag->value; +} + +$results = $client->repo(TagRepository::class)->tagSearch('urg'); +``` + +--- + +## CSV Import + +**v2:** +```php +$csv = file_get_contents('users.csv'); +$client->resource(ResourceType::USER)->import($csv); +``` + +**v3:** +```php +$csv = file_get_contents('users.csv'); +$client->repo(UserRepository::class)->import($csv); +``` + +--- + +## Impersonation (On-Behalf-Of) + +**v2:** +```php +$client->setOnBehalfOfUser('myuser'); +// ... API calls ... +$client->unsetOnBehalfOfUser(); +``` + +**v3:** +```php +// Temporary (auto-cleanup on callback return or exception) +$client->performOnBehalfOf(1, function () use ($client) { + $client->repo(TicketRepository::class)->find(42); +}); + +// Persistent +$client->setOnBehalfOfUser(1); +// ... API calls ... +$client->unsetOnBehalfOfUser(); +``` + +> **Breaking:** v2 used a username string (`'myuser'`). v3 requires a **numeric user ID**. Use `$client->repo(UserRepository::class)->search('login:myuser')` to resolve a username to an ID if needed. + +--- + +## Resource Access — Ruby-Style vs. repo() + +**v3 (explicit, recommended — type-safe with IDE autocomplete):** +```php +$client->repo(TicketRepository::class)->find(1); +$client->repo(UserRepository::class)->find(1); +$client->repo(OrganizationRepository::class)->find(1); +$client->repo(GroupRepository::class)->find(1); +$client->repo(TicketArticleRepository::class)->getForTicket(1); +$client->repo(TicketStateRepository::class)->all(); +$client->repo(TicketPriorityRepository::class)->all(); +$client->repo(TagRepository::class)->add('Ticket', 1, 'urgent'); +$client->repo(TextModuleRepository::class)->find(1); +``` + +**v3 (Ruby-style convenience, deprecated in favor of repo()):** +```php +$client->ticket()->find(1); +$client->user()->find(1); +$client->organization()->find(1); +$client->group()->find(1); +$client->ticket_article()->getForTicket(1); +$client->ticket_state()->all(); +$client->ticket_priority()->all(); +$client->tag()->add('Ticket', 1, 'urgent'); +$client->text_module()->find(1); +``` diff --git a/docs/migration-v3.md b/docs/migration-v3.md new file mode 100644 index 0000000..eb274d5 --- /dev/null +++ b/docs/migration-v3.md @@ -0,0 +1,91 @@ +# Migrating from v2 to v3 + +## Quick Reference: Before → After + +| v2 | v3 | +|---|---| +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, $token)` | +| `$client->resource(TICKET)` | `$client->repo(TicketRepository::class)` | +| `$ticket->get(1)` | `$client->repo(TicketRepository::class)->find(1)` | +| `$ticket->getValue('title')` | `$ticket->title` | +| `$ticket->getValues()` | `$ticket->toArray()` | +| `$ticket->setValue('title', 'x'); $ticket->save()` | `$client->repo(TicketRepository::class)->patch(1, ['title' => 'x'])` | +| `$ticket->search('term')` | `$client->repo(TicketRepository::class)->search('term')` | +| `$ticket->all()` | `$client->repo(TicketRepository::class)->all()` | +| `$ticket->delete()` | `$client->repo(TicketRepository::class)->delete($id)` | +| `if ($ticket->hasError())` | `catch (NotFoundException\|ValidationException $e)` | + +## Why Migrate? + +- **Type safety:** `$ticket->title` with IDE autocomplete instead of `$ticket->getValue('title')` +- **Proper exceptions:** `NotFoundException`, `ValidationException`, `RateLimitException` instead of `hasError()` +- **Unit-testable:** Mock the HTTP layer, tests run in milliseconds without a live Zammad instance +- **PSR-compliant:** PSR-18 HTTP client, PSR-17 request factory, PSR-3 logger +- **Generator-based pagination:** Memory-efficient iteration over large datasets + +## Step-by-Step Migration + +### 1. Update composer.json + +```bash +composer require zammad/zammad-api-client-php:^3.0 +``` + +### 2. Replace Client Instantiation + +```php +// v2 +$client = new \ZammadAPIClient\Client([ + 'url' => 'https://zammad.example/api/v1', + 'http_token' => 'your-token', +]); + +// v3 +$client = \ZammadAPIClient\ZammadClient::withToken( + 'https://zammad.example', + 'your-token' +); +``` + +### 3. Replace Resource Access + +```php +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; + +// v2 +$ticket = $client->resource(\ZammadAPIClient\ResourceType::TICKET); +$ticket->get(1); +$title = $ticket->getValue('title'); + +// v3 +$tickets = $client->repo(TicketRepository::class); +$ticket = $tickets->find(1); +$title = $ticket->title; +``` + +### 4. Replace Error Handling + +```php +use ZammadAPIClient\Endpoints\Tickets\TicketRepository; + +// v2 +$ticket->get(999); +if ($ticket->hasError()) { + $error = $ticket->getError(); +} + +// v3 +try { + $ticket = $client->repo(TicketRepository::class)->find(999); +} catch (\ZammadAPIClient\Exceptions\NotFoundException $e) { + $error = $e->getMessage(); +} +``` + +## Adding a New Resource + +Register the repository in `RepositoryRegistry::DEFINITIONS` (path + DTO class). No changes to `ZammadClient` are required: + +```php +$tickets = $client->repo(TicketRepository::class); +``` diff --git a/docs/v2-reference.md b/docs/v2-reference.md new file mode 100644 index 0000000..66367c2 --- /dev/null +++ b/docs/v2-reference.md @@ -0,0 +1,286 @@ +# v2 Documentation (for reference) + +This client can be used to access the API of the open source helpdesk [Zammad](http://www.zammad.org) via PHP. + +## Zammad version support +This client supports Zammad 3.4.1 and newer. + +## Installation + +### Requirements +The API client needs [composer](https://getcomposer.org/). For installation have a look at its [documentation](https://getcomposer.org/download/). +Additionally, the API client needs PHP 7.2 or newer. + +### Integration into your project +Add the following to the "require" section of your project's composer.json file: +```json +"zammad/zammad-api-client-php": "^2.3" +``` + +### Installing the API client's dependencies +Fetch the API client's code and its dependencies by updating your project's dependencies with composer: +``` +$ composer update +``` + +Once installed, you have to include the generated autoload.php into your project's code: +```php +require_once dirname(__DIR__).'/vendor/autoload.php'; +``` + +## How to use the API client + +### Example code +You can find example code within the directory `examples`. + +### The Client object +Your starting point is the `Client` object: +```php +use ZammadAPIClient\Client; +$client = new Client([ + 'url' => 'https://myzammad.com', // URL to your Zammad installation + 'username' => 'myuser@myzammad.com', // Username to use for authentication + 'password' => 'mypassword', // Password to use for authentication + // 'timeout' => 15, // Sets timeout for requests, defaults to 5 seconds, 0: no timeout + // 'debug' => true, // Enables debug output + // 'verify' => true, // Enabled SSL verification. You can also give a path to a CA bundle file. Default is true. +]); +``` +Besides using a combination of `username` and `password`, you can alternatively give an `http_token` or an `oauth2_token`. +**Important:** You have to activate API access in Zammad. Should be active by default. + +### Fetching a single Resource object +To fetch a `Resource` object by ID, e. g. a ticket with ID 34, use the `Client` object: +```php +use ZammadAPIClient\ResourceType; +$ticket = $client->resource( ResourceType::TICKET )->get(34); +``` + +`$ticket` now is a `Resource` object which holds the data of the ticket and provides all of the methods for setting/getting specific values (like the title of the ticket) and sending changed values to Zammad to update the ticket. + +Note: Once you successfully called `get` on a `Resource` object, you cannot call it again, instead you have to create a new one with `resource`. + +### Accessing values of Resource objects +You can access the values of a `Resource` object via its 'value' methods. +```php +$ticket->setValue( 'title', 'My ticket title' ); +$title = $ticket->getValue('title'); +$all_values = $ticket->getValues(); +``` + +Please note that the API client does not provide checks for nor does it know about the available fields of the `Resource` objects. If you set or get a value of a non-existing field or set an invalid value, Zammad will ignore it or return an error. + +So, how can you know which fields are available? Just fetch an existing `Resource` object and have a look at the returned fields. A fresh Zammad system always contains an object with ID 1 for every resource type. + +Additionally you can have a look at the REST interface documentation of Zammad: + +[Introduction to the REST interface](https://docs.zammad.org/en/latest/api/intro.html) +* [Users](https://docs.zammad.org/en/latest/api/user.html) +* [Groups](https://docs.zammad.org/en/latest/api/group.html) +* [Organizations](https://docs.zammad.org/en/latest/api/organization.html) +* [Tickets](https://docs.zammad.org/en/latest/api/ticket.html) + * [Ticket articles](https://docs.zammad.org/en/latest/api/ticket/articles.html) + * [Ticket priorities](https://docs.zammad.org/en/latest/api/ticket-priority.html) + * [Ticket states](https://docs.zammad.org/en/latest/api/ticket-state.html) +* [Tags](https://docs.zammad.org/en/latest/api/tags.html) + * [Tag list](https://docs.zammad.org/en/latest/api/ticket/tags.html#administration-scope) + * [Linking Tickets](https://docs.zammad.org/en/latest/api/ticket/links.html) + +#### Fetching a ticket's articles +If you already have a ticket object, you can easily fetch its articles: +```php +$ticket_articles = $ticket->getTicketArticles(); +``` + +#### Fetching content of ticket article attachments +The content of ticket article attachments can be fetched with a call of `getAttachmentContent()` of the ticket article resource object: +```php +$attachment_content = $ticket_article->getAttachmentContent(23); +``` + +In the above example 23 is the ID of the attachment. This ID can be found within the `attachments` array of the ticket article data. Usually you want to loop over this array to fetch the content of all attachments. + +`getAttachmentContent()` returns the attachment content as a string, ready to use. + +### Updating Resource objects +If you fetched a `Resource` object and changed some values, you have to send your changes to Zammad. You do this with a simple call: +```php +$ticket->save(); +``` + +### Creating Resource objects +To create a new `Resource` object, use the following code (example): +```php +use ZammadAPIClient\ResourceType; + +$ticket = $client->resource( ResourceType::TICKET ); +$ticket->setValue( 'title', 'My new ticket' ); +// ... +// Set additional values +// ... +$ticket->save(); // Will create a new ticket object in Zammad +``` + +### Searching Resource objects +Some types of resources can be searched, pagination is available. +```php +use ZammadAPIClient\ResourceType; + +// Fulltext search +$tickets = $client->resource( ResourceType::TICKET )->search('some text'); + +// Field specific search +$tickets = $client->resource( ResourceType::TICKET )->search('title:My Title'); + +// Field specific search with more than one field +$tickets = $client->resource( ResourceType::TICKET )->search('title:My Title AND priority_id:1'); + +// Pagination: Page 1, 25 entries per page +$tickets = $client->resource( ResourceType::TICKET )->search( 'some text', 1, 25 ); +``` + +### Fetching 'all' Resource objects +For some types of resources, all available objects can be fetched, pagination is available. + +```php +use ZammadAPIClient\ResourceType; + +// Fetch all tickets (keep in mind the server-side limit, see 'Searching Resource objects') +$tickets = $client->resource( ResourceType::TICKET )->all(); + +// Fetch all tickets with pagination (keep in mind the server-side limit, see 'Searching Resource objects'), page 4, 50 entries per page +$tickets = $client->resource( ResourceType::TICKET )->all( 4, 50 ); +``` + +### Deleting a Resource object +To be able to delete a `Resource` object that exists in Zammad, you must first fetch it from Zammad, either via `get`, `all` or `search`. +You can also delete a newly created `Resource` object that has not been sent to Zammad yet. But this should only rarely be necessary because you can simply create a new `Resource` object via the `Client` object. +To delete a `Resource` object, simply call `delete` on it: +```php +$ticket->delete(); +``` + +### Working with tags + +#### Adding a tag to an object + +Zammad can assign tags to an object. Currently this is only supported for ticket objects. + +```php +use ZammadAPIClient\ResourceType; + +// The third parameter 'Ticket' is the object type for which the ID will be given as first parameter. +$client->resource( ResourceType::TAG )->add( $ticket_id, 'tag 1', 'Ticket' ); +``` + + +#### Remove a tag from an object + +```php +use ZammadAPIClient\ResourceType; + +$client->resource( ResourceType::TAG )->remove( $ticket_id, 'tag 1', 'Ticket' ); +``` + +#### Getting all tags assigned to an object + +```php +use ZammadAPIClient\ResourceType; + +// The second parameter 'Ticket' is the object type for which the ID will be given as first parameter. +$tag = $client->resource( ResourceType::TAG )->get( $ticket_id, 'Ticket' ); + +// [ 'tag 1', 'tag 2' ] +$tags = $tag->getValue('tags') +``` + +#### Search for Tags + +```php +use ZammadAPIClient\ResourceType; + +$tags = $client->resource( ResourceType::TAG )->search('my tag'); +``` +### Linking Tickets + +#### Linking two Tickets + +Zammad can link two or more Ticket objects. Allowed Link Types are `normal`, `parent` or `child`. + +```php +use ZammadAPIClient\ResourceType; + +// First parameter $sourceTicket is the Ticket that should be linked +// Second parameter $targetTicket is the Ticket that $sourceTicket should be linked to +// Third parameter is the LinkType the $sourceTicket will be linked to $targetTicket with. +$client->resource( ResourceType::LINK )->add( $sourceTicket, $targetTicket, 'normal' ); +``` + +### Object import + +Besides the usual methods available for objects, there is also a method available to import these via CSV. Example for text module CSV import: + +```php +use ZammadAPIClient\ResourceType; + +$text_modules_csv_string = file_get_contents('text_modules.csv'); + +$client->resource( ResourceType::TEXT_MODULE )->import($text_modules_csv_string); + +``` + +### Handling Zammad errors +When you access Zammad, you **always** will get a `Resource` object (or an array of such objects) in return, regardless if Zammad returned data or executed your request. In case of errors (e. g. that above ticket with ID 34 does not exist in Zammad), you will get a `Resource` object with a set error which can be checked with the following code: +```php +if ( $ticket->hasError() ) { + print $ticket->getError(); +} +``` + +If you additionally need more detailed information about connection/request errors, you can access the `Response` object of the `Client` object. It holds the response of the last request that was made. +```php +$last_response = $client->getLastResponse(); +``` + +### Executing an API call on behalf of another user +If you want Zammad to execute an API call on behalf of another user than the one you used for authentication, use the following code before executing the API call(s): +```php +$client->setOnBehalfOfUser('myuser'); +``` +This sets the `From` HTTP header. Any API call after above code will use this setting. If you want to return to using the user you used for authentication, call: +```php +$client->unsetOnBehalfOfUser(); +``` + +## Available resource types and their access methods + +To be able to use the 'short form' for the resource type, add a +```php +use ZammadAPIClient\ResourceType; +``` + +to your code. You then can reference the resource type like +```php +$client->resource( ResourceType::TICKET ); +``` + +|Resource type|get|all|search|save|delete|add|remove|import| +|-------------|:-:|:-:|:----:|:--:|:----:|:-:|:----:|:----:| +| TICKET|✔|✔|✔|✔|✔|–|–|–| +| TICKET_ARTICLE|✔|–|✔|✔|✔|–|–|–| +| TICKET_STATE|✔|✔|–|✔|✔|–|–|–| +| TICKET_PRIORITY|✔|✔|–|✔|✔|–|–|–| +| TEXT_MODULE|✔|✔|–|✔|✔|–|–|✔| +| ORGANIZATION|✔|✔|✔|✔|✔|–|–|✔| +| GROUP|✔|✔|–|✔|✔|–|–|–| +| USER|✔|✔|✔|✔|✔|–|–|✔| +| TAG|✔|✔|✔|✔|✔|✔|✔|–| +| LINK|✔|–|–|–|–|✔|✔|–| + +## Contributing + +Bug reports and pull requests are welcome on [GitHub](https://github.com/zammad/zammad-api-client-php). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. + +## License + +AGPL-3.0 or MIT. diff --git a/examples/config.php.dist b/examples/config.php.dist deleted file mode 100644 index e9564ef..0000000 --- a/examples/config.php.dist +++ /dev/null @@ -1,17 +0,0 @@ - System => API) - -$zammad_api_client_config = [ - 'url' => 'https://myzammad.example.com', - // with HTTP token: - 'http_token' => '...', - - // or with username and password - // 'username' => 'master@example.com', - // 'password' => 'test', - - // or with OAuth2 token: - //'oauth2_token' => '...', -]; diff --git a/examples/cookbook.php b/examples/cookbook.php new file mode 100644 index 0000000..56e2866 --- /dev/null +++ b/examples/cookbook.php @@ -0,0 +1,146 @@ +repo(TicketRepository::class); + +// ── Caching reference data ────────────────────────────────────── +// States and priorities rarely change. The simple static cache below +// avoids repeated HTTP calls within one script run. For production: +// PSR-6/16 with Memcached: $cachePool->get('zammad.priorities') +// Laravel: Cache::remember('zammad.priorities', 3600, fn() => ...) +// Plain PHP (apcu): apcu_fetch('zammad.priorities') ?? refresh... +function memoize(string $key, callable $fn): mixed { + static $cache = []; + return $cache[$key] ??= $fn(); +} + +$priorities = memoize('priorities', fn() => + [...$client->repo(\ZammadAPIClient\Endpoints\TicketPriorities\TicketPriorityRepository::class)->all()] +); +$states = memoize('states', fn() => + [...$client->repo(\ZammadAPIClient\Endpoints\TicketStates\TicketStateRepository::class)->all()] +); + +$normalPriorityId = null; +$openStateId = null; + +foreach ($priorities as $p) { + if (mb_stripos($p->name, 'normal') !== false) { + $normalPriorityId = $p->id; + break; + } +} + +foreach ($states as $s) { + if (mb_stripos($s->name, 'open') !== false) { + $openStateId = $s->id; + break; + } +} + +// ── Recipe 1: Create a ticket to work with ────────────────────── +$hostTicket = $repo->create(new TicketDTO( + title: 'Cookbook Host ' . date('Y-m-d H:i:s'), + customer_id: 1, + group_id: 1, + priority_id: $normalPriorityId ?? 2, + state_id: $openStateId ?? 1, + article: [ + 'subject' => 'Host ticket', + 'body' => 'This is the ticket used by the cookbook recipes.', + 'type' => TicketArticleType::Note->value, + ], +)); +$ticketId = $hostTicket->id; +echo "✓ Created host ticket #{$ticketId}: {$hostTicket->title}\n"; + +// ── Recipe 2: Fetch a ticket ──────────────────────────────────── +$ticket = $repo->find($ticketId); +echo "✓ Fetched ticket #{$ticketId}: {$ticket->title}\n"; + +// ── Recipe 3: Stateful resource with changes tracking ─────────── +$resource = $repo->resource($ticketId); +$resource->title = 'Updated via Resource ' . date('H:i:s'); +$resource->save(); +echo "✓ Ticket #{$resource->id} updated: {$resource->title}\n"; + +// ── Recipe 4: Create via DTO ──────────────────────────────────── +$created = $repo->create(new TicketDTO( + title: 'Cookbook Test ' . date('H:i:s'), + customer_id: 1, + group_id: 1, + priority_id: $normalPriorityId ?? 2, + state_id: $openStateId ?? 1, + article: [ + 'subject' => 'Cookbook Test', + 'body' => 'Created via cookbook recipe 4', + 'type' => TicketArticleType::Note->value, + ], +)); +echo "✓ Ticket #{$created->id} created: {$created->title}\n"; + +// ── Recipe 5: Paginated list with navigation ──────────────────── +$list = $repo->list(['expand' => 'true']); +$list->page(1); +echo "✓ Page 1: " . count($list) . " tickets\n"; + +// ── Recipe 6: Error handling ──────────────────────────────────── +try { + $repo->find(999999); +} catch (NotFoundException $e) { + echo "✓ NotFoundException caught: {$e->getMessage()}\n"; +} + +// ── Recipe 7: Delete a ticket via repository ──────────────────── +$repo->delete($created->id); +echo "✓ Ticket #{$created->id} deleted\n"; + +// ── Recipe 8: On-Behalf-Of impersonation ──────────────────────── +$client->performOnBehalfOf(1, function () use ($repo, $ticketId) { + echo " (acting as user #1) Ticket #{$ticketId} title: " + . $repo->find($ticketId)->title . "\n"; +}); +echo "✓ Impersonation complete\n"; + +// ── Recipe 9: Search ──────────────────────────────────────────── +$count = 0; +foreach ($repo->search('cookbook') as $t) { + $count++; +} +echo "✓ Search found {$count} tickets\n"; + +// Clean up the host ticket +$repo->delete($ticketId); +echo "✓ Host ticket #{$ticketId} cleaned up\n"; + +echo "\nAll recipes executed.\n"; diff --git a/examples/tag_admin.php b/examples/tag_admin.php deleted file mode 100644 index 6150af9..0000000 --- a/examples/tag_admin.php +++ /dev/null @@ -1,68 +0,0 @@ - 'https://my.zammad.com', - 'username' => 'my-username', - 'password' => 'my-password', -]); - -// List all tags (administration scope) -$tags = $client->resource(ResourceType::TAG)->all(); -echo "All tags:\n"; -foreach ($tags as $tag) { - echo sprintf( - " ID: %s, Name: %s, Count: %s\n", - $tag->getID(), - $tag->getValue('name'), - $tag->getValue('count') - ); -} - -// Create a new tag -$tag = $client->resource(ResourceType::TAG); -$tag->setValue('name', 'example-tag'); -$tag->save(); - -echo "\nTag 'example-tag' created.\n"; - -// Find the created tag to get its ID -$tag_id = null; -$tags = $client->resource(ResourceType::TAG)->all(); -foreach ($tags as $t) { - if ($t->getValue('name') === 'example-tag') { - $tag_id = $t->getID(); - break; - } -} - -if ($tag_id) { - echo "Tag ID: $tag_id\n"; - - // Rename the tag (update) - // Set the tag's ID so save() will call update() instead of create() - $tag = $client->resource(ResourceType::TAG); - $tag->setRemoteData(['id' => $tag_id]); - $tag->setValue('name', 'example-tag-renamed'); - $tag->save(); - - echo "Tag renamed to 'example-tag-renamed'.\n"; - - // Delete the tag - $tag = $client->resource(ResourceType::TAG); - $tag->setRemoteData(['id' => $tag_id]); - $tag->delete(); - - echo "Tag deleted.\n"; -} diff --git a/examples/ticket.php b/examples/ticket.php deleted file mode 100644 index 21c9a95..0000000 --- a/examples/ticket.php +++ /dev/null @@ -1,73 +0,0 @@ - 1, - 'priority_id' => 1, - 'state_id' => 1, - 'title' => $ticket_text, - 'customer_id' => 1, - 'article' => [ - 'subject' => $ticket_text, - 'body' => $ticket_text, - ], -]; - -$ticket = $client->resource( ResourceType::TICKET ); -$ticket->setValues($ticket_data); -$ticket->save(); -exitOnError($ticket); - -$ticket_id = $ticket->getID(); // same as getValue('id') - -// -// Fetch ticket -// -$ticket = $client->resource( ResourceType::TICKET )->get($ticket_id); -exitOnError($ticket); -print_r( $ticket->getValues() ); - -// -// Fetch ticket articles -// -$ticket_articles = $ticket->getTicketArticles(); -foreach ( $ticket_articles as $ticket_article ) { - print_r($ticket_article); -} - -// -// Search ticket -// -$tickets = $client->resource( ResourceType::TICKET )->search($ticket_text); -if ( !is_array($tickets) ) { - exitOnError($tickets); -} -else { - print 'Found ' . count($tickets) . ' ticket(s) with text ' . $ticket_text . "\n"; -} - -// -// Delete created ticket -// -$ticket->delete(); -exitOnError($ticket); - -function exitOnError($object) -{ - if ( $object->hasError() ) { - print $object->getError() . "\n"; - exit(1); - } -} diff --git a/examples/user.php b/examples/user.php deleted file mode 100644 index c07cb18..0000000 --- a/examples/user.php +++ /dev/null @@ -1,58 +0,0 @@ - $email_address, - 'email' => $email_address, -]; - -$user = $client->resource( ResourceType::USER ); -$user->setValues($user_data); -$user->save(); -exitOnError($user); - -$user_id = $user->getID(); // same as getValue('id') - -// -// Fetch user -// -$user = $client->resource( ResourceType::USER )->get($user_id); -exitOnError($user); -print_r( $user->getValues() ); - -// -// Search user -// -$users = $client->resource( ResourceType::USER )->search($email_address); -if ( !is_array($users) ) { - exitOnError($users); -} -else { - print 'Found ' . count($users) . ' user(s) with email address ' . $email_address . "\n"; -} - -// -// Delete created user -// -$user->delete(); -exitOnError($user); - -function exitOnError($object) -{ - if ( $object->hasError() ) { - print $object->getError() . "\n"; - exit(1); - } -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..8330956 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,16 @@ + + + PSR-12 coding standard for the Zammad PHP API client. + + src + test + examples + + */vendor/* + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..231d7e0 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +parameters: + level: max + paths: + - src/ + scanDirectories: + - test/ + excludePaths: + # Bridge classes require Laravel/Symfony packages at runtime only. + - src/Bridge/ + ignoreErrors: + - + message: '#return type with generic class.*does not specify its types#' + path: src/ZammadClient.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2cd5e9e..f6a581d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,27 +1,34 @@ - - + colors="true" + cacheDirectory=".phpunit.cache"> - - ./test/ + + test/Unit + + + test/Integration - - - - + + + integration + testcontainers + + + + + src + + + + + + + + + diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php new file mode 100644 index 0000000..6188c61 --- /dev/null +++ b/src/Bridge/LaravelServiceProvider.php @@ -0,0 +1,94 @@ + [ + * // ... + * ZammadAPIClient\Bridge\LaravelServiceProvider::class, + * ], + * ``` + * + * - Publish the configuration file: + * + * ```bash + * php artisan vendor:publish --tag=zammad-config + * ``` + * + * This copies the default config to `config/zammad.php`. + * + * - Set your credentials in `.env`: + * + * ```env + * ZAMMAD_URL=https://zammad.example.com/api/v1 + * ZAMMAD_TOKEN=your-api-token + * ``` + * + * Or edit `config/zammad.php` directly. + * + * **Usage** + * + * ```php + * use ZammadAPIClient\Endpoints\Tickets\TicketRepository; + * use ZammadAPIClient\ZammadClient; + * + * class TicketController + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function show(int $id) + * { + * $ticket = $this->zammad->repo(TicketRepository::class)->find($id); + * } + * } + * + * // Or resolve manually from the container: + * $tickets = app(ZammadClient::class)->repo(TicketRepository::class)->all(); + * ``` + * + * **Configuration precedence** + * + * 1. `config/zammad.php` values (after `vendor:publish`) + * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) + * 3. Built-in defaults (`http://127.0.0.1:8098/api/v1`, empty token) + * + * @see ZammadClient::withToken() + */ +final class LaravelServiceProvider extends ServiceProvider +{ + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../../config/zammad.php' => config_path('zammad.php'), + ], 'zammad-config'); + } + + public function register(): void + { + $this->app->singleton(ZammadClient::class, function (): ZammadClient { + $url = config('zammad.url') ?: env('ZAMMAD_URL', 'http://127.0.0.1:8098'); + $token = config('zammad.token') ?: env('ZAMMAD_TOKEN', ''); + + return ZammadClient::withToken($url, $token); + }); + } +} diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php new file mode 100644 index 0000000..8d62eef --- /dev/null +++ b/src/Bridge/SymfonyBundle.php @@ -0,0 +1,108 @@ + ['all' => true], + * ]; + * ``` + * + * - Configure the connection in `config/packages/zammad.yaml`: + * + * ```yaml + * zammad: + * url: '%env(ZAMMAD_URL)%' + * token: '%env(ZAMMAD_TOKEN)%' + * ``` + * + * - Set the environment variables (`.env` or `.env.local`): + * + * ```env + * ZAMMAD_URL=https://zammad.example.com/api/v1 + * ZAMMAD_TOKEN=your-api-token + * ``` + * + * **Usage** + * + * ```php + * use ZammadAPIClient\Endpoints\Tickets\TicketRepository; + * use ZammadAPIClient\ZammadClient; + * + * class TicketService + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function findTicket(int $id) + * { + * return $this->zammad->repo(TicketRepository::class)->find($id); + * } + * } + * + * // Or resolve manually from the container: + * // $container->get(ZammadClient::class)->repo(TicketRepository::class)->all(); + * // $container->get('zammad_client')->repo(TicketRepository::class)->all(); + * ``` + * + * @see ZammadClient::withToken() + */ +final class SymfonyBundle extends Bundle +{ + public function getContainerExtension(): ?ExtensionInterface + { + return new class implements ExtensionInterface { + public function load(array $configs, ContainerBuilder $container): void + { + $resolved = []; + foreach ($configs as $config) { + $resolved = array_merge($resolved, $config); + } + + $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098/api/v1'); + $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); + + $client = ZammadClient::withToken($url, $token); + $container->set(ZammadClient::class, $client); + $container->setAlias('zammad_client', ZammadClient::class); + $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); + } + + public function getNamespace(): string + { + return ''; + } + + public function getXsdValidationBasePath(): string + { + return ''; + } + + public function getAlias(): string + { + return 'zammad'; + } + }; + } +} diff --git a/src/Client.php b/src/Client.php deleted file mode 100644 index 835195a..0000000 --- a/src/Client.php +++ /dev/null @@ -1,237 +0,0 @@ - - */ - -namespace ZammadAPIClient; - -use ZammadAPIClient\Client\Response; - -class Client -{ - const API_VERSION = 'v1'; - - private $http_client; - private $last_response; - private $options; - private $on_behalf_of_user; - - /** - * Creates a Client 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, - * ]; - * - * @param ?HTTPClientInterface $client Optional, pass in custom HTTP client. - * - * @return Object Client object - */ - public function __construct( array $options = [], ?HTTPClientInterface $client = null) - { - $this->options = $options; - $this->http_client = $client ?? new HTTPClient($options); - } - - /** - * Executes a request. - * - * @param String $method GET, PUT, POST, DELETE - * @param String $url E. g. tickets/1 - * @param Array $url_parameters E. g. [ 'expand' => true, ] - * - * @return Response object - */ - private function request ( $method, $url, array $url_parameters = [], array $options = [] ) - { - $method = mb_strtoupper($method); - - if (!empty($url_parameters)) { - $options['query'] = $url_parameters; - } - - // Set JSON headers - $options['headers']['Accept'] = 'application/json'; - $options['headers']['Content-Type'] = 'application/json; charset=utf-8'; - - // Set "on behalf of user" header (X-On-Behalf-Of was deprecated in Zammad 6.5 in favor of From) - if ( !empty($this->on_behalf_of_user) ) { - $options['headers']['From'] = $this->on_behalf_of_user; - } - - $http_client_response = $this->http_client->request( $method, $url, $options ); - if ( !is_object($http_client_response) ) { - throw new \RuntimeException('Unable to create HTTP client request.'); - } - - // Turn HTTP client's response into our own. - $response = new Response( - $http_client_response->getStatusCode(), - $http_client_response->getReasonPhrase(), - $http_client_response->getBody(), - $http_client_response->getHeaders() - ); - - $this->setLastResponse($response); - - return $response; - } - - /** - * Executes GET request. - * - * @param String $url E. g. tickets/1 - * @param Array $url_parameters E. g. [ 'expand' => true, ] - * - * @return Response object - */ - public function get( $url, array $url_parameters = [] ) - { - $response = $this->request( - 'GET', - $url, - $url_parameters - ); - - return $response; - } - - /** - * Executes POST request. - * - * @param String $url E. g. tickets/1 - * @param Array $data Array with data to send as JSON. - * @param Array $url_parameters E. g. [ 'expand' => true, ] - * - * @return Response object - */ - public function post( $url, array $data = [], array $url_parameters = [] ) - { - $response = $this->request( - 'POST', - $url, - $url_parameters, - [ 'json' => $data, ] - ); - - return $response; - } - - /** - * Executes PUT request. - * - * @param String $url E. g. tickets/1 - * @param Array $data Array with data to send as JSON. - * @param Array $url_parameters E. g. [ 'expand' => true, ] - * - * @return Response object - */ - public function put( $url, array $data = [], array $url_parameters = [] ) - { - $response = $this->request( - 'PUT', - $url, - $url_parameters, - [ 'json' => $data, ] - ); - - return $response; - } - - /** - * Executes DELETE request. - * - * @param String $url E. g. tickets/1 - * @param Array $url_parameters E. g. [ 'expand' => true, ] - * - * @return Response object - */ - public function delete( $url, array $url_parameters = [], array $data = [] ) - { - $options = []; - if ( !empty($data) ) { - $options['json'] = $data; - } - - $response = $this->request( - 'DELETE', - $url, - $url_parameters, - $options - ); - - return $response; - } - - /** - * Creates a resource object. - * - * @param String $resource_type ResourceType::TICKET - * - * @return Object Resource object - */ - public function resource($resource_type) - { - $resource_object = new $resource_type($this); - return $resource_object; - } - - /** - * Sets user on behalf of which API calls will be executed. - * - * @param String $user User ID, login or email address - */ - public function setOnBehalfOfUser($user) - { - $this->on_behalf_of_user = $user; - } - - /** - * Unsets user on behalf of which API calls will be executed. - * API calls will then be called again by the user who is being used - * for authentication. - */ - public function unsetOnBehalfOfUser() - { - $this->on_behalf_of_user = null; - } - - /** - * Stores Response object as last Response object. - * - * @param Object $response Response object to store. - */ - private function setLastResponse( Response $response ) - { - $this->last_response = $response; - } - - /** - * Returns last Response object. - * - * @return objects Last Response object. - */ - public function getLastResponse() - { - return $this->last_response; - } -} diff --git a/src/Client/Response.php b/src/Client/Response.php deleted file mode 100644 index fedfa70..0000000 --- a/src/Client/Response.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ - -namespace ZammadAPIClient\Client; - -class Response -{ - private $status_code; - private $reason_phrase; - private $body; - private $headers; - private $data = []; - private $error = null; - - public function __construct( - $status_code, - $reason_phrase, - $body, - array $headers = [] - ) - { - $this->status_code = intval($status_code); - $this->reason_phrase = $reason_phrase; - $this->body = $body; - $this->headers = $headers; - - // Support case insensitve HTTP header names (Content-Type vs content-type) - $lowercase_headers = array_change_key_case($this->headers, CASE_LOWER); - - // Store decoded JSON data, if present - if ( - !empty( $lowercase_headers['content-type'] ) - && mb_strpos( $lowercase_headers['content-type'][0], 'application/json;' ) !== false - ) { - $this->data = json_decode( $this->body, true ); - - if ( !empty( $this->data['error'] ) ) { - $this->error = $this->data['error']; - } - } - } - - public function getStatusCode() - { - return $this->status_code; - } - - public function getReasonPhrase() - { - return $this->reason_phrase; - } - - public function getStatusMessage() - { - return $this->status_code . ' - ' . $this->reason_phrase; - } - - public function getBody() - { - return $this->body; - } - - public function getHeaders() - { - return $this->headers; - } - - public function getData() - { - return $this->data; - } - - public function getError() - { - return $this->error; - } - - public function hasError() - { - return !empty( $this->getError() ); - } -} diff --git a/src/Core/AbstractRepository.php b/src/Core/AbstractRepository.php new file mode 100644 index 0000000..7b6e946 --- /dev/null +++ b/src/Core/AbstractRepository.php @@ -0,0 +1,356 @@ +`). + * 2. Implement {@see self::getListKey()} to name the JSON array key that holds + * the resource list in paginated list responses (varies per endpoint). + * 3. Optionally add endpoint-specific convenience methods (e.g. `getForTicket`). + * + * All repositories are instantiated via {@see \ZammadAPIClient\ZammadClient::repo()}, + * which injects the shared `RequestHandler` and the wiring defined in + * {@see \ZammadAPIClient\Core\RepositoryRegistry::DEFINITIONS}. + * + * @template T of DTOInterface + * @implements RepositoryInterface + */ +abstract class AbstractRepository implements RepositoryInterface +{ + public const DEFAULT_PAGE_SIZE = 100; + + /** + * @param RequestHandlerInterface $handler Shared HTTP transport; injected by the client. + * @param string $resourcePath API path segment for this resource (e.g. `tickets`). + * @param class-string $dtoClass DTO class used to hydrate API responses. + * @param int $pageSize Number of items fetched per page during cursor pagination. + */ + public function __construct( + protected RequestHandlerInterface $handler, + protected string $resourcePath, + protected string $dtoClass, + protected int $pageSize = self::DEFAULT_PAGE_SIZE, + ) { + } + + /** + * Returns the fully-qualified DTO class name associated with this repository. + * + * Useful when the caller needs to instantiate or inspect the DTO without + * going through a repository method (e.g. in generic utility code). + * + * @return class-string + */ + public function getDtoClass(): string + { + return $this->dtoClass; + } + + /** + * Returns the JSON array key that contains the resource list in paginated responses. + * + * Zammad's list endpoints wrap results in a keyed array (e.g. `{"tickets": [...], "assets": {...}}`). + * Each endpoint uses a different key; subclasses declare the correct one here so + * {@see self::extractItems()} can locate the items without guessing. + */ + abstract protected function getListKey(): string; + + /** + * Fetches a single resource by its Zammad-assigned ID. + * + * The `expand=true` query parameter is appended automatically so that + * Zammad resolves referenced objects (e.g. owner name instead of just ID) + * and returns them inline. Without it some relation fields would be null. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If no resource with $id exists. + */ + public function find(int $id): DTOInterface + { + return $this->dtoClass::fromArray( + $this->handler->get("{$this->resourcePath}/{$id}", ['expand' => 'true']), + ); + } + + /** + * Ruby-style stateful resource: fetch by ID, returns a mutable wrapper + * with changes tracking. + * + * $ticket = $client->ticket()->resource(1); + * $ticket->title = 'New'; + * $ticket->save(); + * + * @return Resource Resource wrapper with changes tracking + */ + public function resource(int $id): Resource + { + return new Resource($this->find($id), $this->handler, $this->resourcePath); + } + + /** + * Ruby-style paginated list with page navigation. + * + * $list = $client->ticket()->list(); + * $list->page(2); + * $list->each(function ($t) { echo $t->title; }); + * + * @param array $query + * @return PaginatedList + */ + public function list(array $query = []): PaginatedList + { + return new PaginatedList( + $this->handler, + $this->dtoClass, + $this->resourcePath, + $query, + $this->pageSize, + $this->getListKey(), + ); + } + + /** + * Ruby-style search list with page navigation. + * + * @param array $query + * @return PaginatedList + */ + public function searchList(string $term, array $query = []): PaginatedList + { + return new PaginatedList( + $this->handler, + $this->dtoClass, + "{$this->resourcePath}/search", + array_merge(['query' => $term], $query), + $this->pageSize, + $this->getListKey(), + ); + } + + /** + * Streams all resources using transparent cursor pagination. + * + * Pages are fetched lazily via a PHP generator: no data is loaded until + * the caller iterates. A page is considered complete when fewer items than + * $pageSize are returned, at which point iteration stops. This avoids an + * extra request to check for an empty last page. + * + * @param array $query Optional API query parameters applied to every page request. + * @return Generator + */ + public function all(array $query = []): iterable + { + return $this->paginate("{$this->resourcePath}", $query); + } + + /** + * Searches resources using Zammad's full-text search and paginates lazily. + * + * $term is forwarded verbatim as the `query` API parameter. Additional + * parameters (e.g. `limit`, `sort_by`) can be passed via $query. + * + * @param array $query Optional API query parameters merged with the search term. + * @return Generator + */ + public function search(string $term, array $query = []): iterable + { + return $this->paginate( + "{$this->resourcePath}/search", + array_merge(['query' => $term], $query), + ); + } + + /** + * Fetches exactly one explicit page of resources. + * + * Unlike {@see self::all()}, which uses transparent cursor pagination, this + * method exposes the raw `page` / `per_page` parameters so that the legacy + * client can replicate v2's explicit pagination behaviour. + * + * Not intended for use in new code — prefer {@see self::all()}. + * + * @param array $query Optional API query parameters. + * @return array + */ + public function allPage(int $page, int $perPage, array $query = []): array + { + $params = array_merge($query, [ + 'page' => (string) $page, + 'per_page' => (string) $perPage, + ]); + + return $this->hydrateList($this->handler->get($this->resourcePath, $params)); + } + + /** + * Fetches exactly one explicit page of search results. + * + * The legacy counterpart to {@see self::search()}; exists to support v2's + * explicit pagination. Not intended for new code. + * + * @param array $query Optional API query parameters merged with the search term. + * @return array + */ + public function searchPage(string $term, int $page, int $perPage, array $query = []): array + { + $params = array_merge(['query' => $term], $query, [ + 'page' => (string) $page, + 'per_page' => (string) $perPage, + ]); + + return $this->hydrateList($this->handler->get("{$this->resourcePath}/search", $params)); + } + + /** + * @param array $query + * @return Generator + */ + 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(); + } + + /** + * 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/Cast.php b/src/Core/Cast.php new file mode 100644 index 0000000..5e32c87 --- /dev/null +++ b/src/Core/Cast.php @@ -0,0 +1,114 @@ + $data + */ + public static function dateTime(array $data, string $key): ?DateTimeImmutable + { + $value = $data[$key] ?? null; + + if (!is_string($value) || $value === '') { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (DateMalformedStringException) { + return null; + } + } + + /** + * Extracts a key from $data as a string, falling back to $default. + * + * Non-string values (e.g. integers returned by the API in unexpected fields) + * are ignored and the default is returned instead of casting silently. + * + * @param array $data + */ + public static function string( + array $data, + string $key, + string $default = '', + ): string { + $value = $data[$key] ?? null; + + return is_string($value) ? $value : $default; + } + + /** + * Extracts a key from $data as a string, or null if absent or not a string. + * + * @param array $data + */ + public static function stringOrNull(array $data, string $key): ?string + { + $value = $data[$key] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * Extracts a key from $data as an int, or null if absent. + * + * Any scalar value (string, float, bool) is cast via `(int)` to handle + * Zammad occasionally returning IDs as numeric strings (e.g. `"42"`). + * Non-scalar values (arrays, objects) return null. + * + * @param array $data + */ + public static function intOrNull(array $data, string $key): ?int + { + $value = $data[$key] ?? null; + + return is_scalar($value) ? (int) $value : null; + } + + /** + * Extracts a key from $data as a bool, or null if the key is absent. + * + * A present value of 0 or false casts to `false` (not null), so callers + * can distinguish "field explicitly set to false" from "field not present". + * + * @param array $data + */ + public static function boolOrNull(array $data, string $key): ?bool + { + $value = $data[$key] ?? null; + + if ($value === null || $value === '') { + return null; + } + + return (bool) $value; + } +} diff --git a/src/Core/ConnectionConfig.php b/src/Core/ConnectionConfig.php new file mode 100644 index 0000000..36309e8 --- /dev/null +++ b/src/Core/ConnectionConfig.php @@ -0,0 +1,19 @@ + $repositoryClass + * @return T + */ + public function repo(string $repositoryClass): AbstractRepository; + + /** + * Returns the underlying PSR-18 request handler for raw API access. + * + * Use this escape hatch when you need to call an endpoint that has no + * dedicated repository (e.g. ticket deletion, direct `/tag_list` access). + */ + public function getHandler(): RequestHandlerInterface; +} diff --git a/src/Core/Contracts/DTOInterface.php b/src/Core/Contracts/DTOInterface.php new file mode 100644 index 0000000..3ea5960 --- /dev/null +++ b/src/Core/Contracts/DTOInterface.php @@ -0,0 +1,71 @@ + $data Raw JSON-decoded response from the API. + * @return static + */ + public static function fromArray(array $data): static; + + /** + * Serializes the DTO to an associative array suitable for API requests. + * + * Keys match the API field names. Null values are omitted from the result; + * use a dedicated patch DTO + * (e.g. {@see \ZammadAPIClient\Endpoints\Tickets\TicketUpdateDTO}) + * when you need to explicitly send null to the API. + * + * @return array + */ + public function toArray(): array; + + /** + * Returns the server-assigned ID, or null before the object is persisted. + * + * The ID is assigned by Zammad on creation and is never set by the client. + * A null return value signals that the DTO represents an unsaved resource. + */ + public function id(): ?int; + + /** + * Alias of {@see self::toArray()} required by JsonSerializable. + * + * Enables direct JSON encoding: `json_encode($dto)` produces the same + * output as `json_encode($dto->toArray())`. + * + * @return array + */ + public function jsonSerialize(): array; +} diff --git a/src/Core/Contracts/DeletableInterface.php b/src/Core/Contracts/DeletableInterface.php new file mode 100644 index 0000000..a6ba458 --- /dev/null +++ b/src/Core/Contracts/DeletableInterface.php @@ -0,0 +1,23 @@ + $baseQuery Additional query params (e.g. search term). + * + * @return array{items: list, total_count: ?int} + */ + public function fetch(int $page, int $perPage, array $baseQuery): array; +} diff --git a/src/Core/Contracts/PatchableInterface.php b/src/Core/Contracts/PatchableInterface.php new file mode 100644 index 0000000..40da334 --- /dev/null +++ b/src/Core/Contracts/PatchableInterface.php @@ -0,0 +1,15 @@ + + */ + public function toPatchArray(): array; +} diff --git a/src/Core/Contracts/RepositoryInterface.php b/src/Core/Contracts/RepositoryInterface.php new file mode 100644 index 0000000..94394c5 --- /dev/null +++ b/src/Core/Contracts/RepositoryInterface.php @@ -0,0 +1,86 @@ +`). The generic parameter is + * covariant so a `RepositoryInterface` can be passed where a + * `RepositoryInterface` is expected. + * + * The interface extends `IteratorAggregate` so that repositories can be used + * in `foreach` loops directly — equivalent to calling {@see self::all()}. + * + * @template-covariant T of DTOInterface + * @extends IteratorAggregate + */ +interface RepositoryInterface extends IteratorAggregate +{ + /** + * Fetches a single resource by server-assigned ID. + * + * @throws \ZammadAPIClient\Exceptions\NotFoundException If no resource with $id exists. + * @throws \ZammadAPIClient\Exceptions\AuthenticationException On authentication failure. + * @return T + */ + public function find(int $id): DTOInterface; + + /** + * Returns all resources, lazily fetched page by page. + * + * The iterable is backed by a generator that yields one page at a time via + * cursor pagination. Do not convert to an array on large datasets without + * applying a limit first. + * + * @param array $query Optional API query parameters (e.g. filters). + * @return iterable + */ + public function all(array $query = []): iterable; + + /** + * Returns resources matching the full-text search term. + * + * Zammad's search endpoint is used; $term is passed verbatim. Additional + * API parameters (e.g. `limit`) can be supplied via $query. + * + * @param array $query Optional API query parameters. + * @return iterable + */ + public function search(string $term, array $query = []): iterable; + + /** + * Creates a new resource and returns the server-confirmed state. + * + * The $dto must not have an ID set. The returned DTO will have the + * server-assigned ID, created_at, and updated_at values populated. + * + * @throws \ZammadAPIClient\Exceptions\ValidationException If the API rejects the payload. + * @return T + */ + public function create(DTOInterface $dto): DTOInterface; + + /** + * Updates a resource via HTTP PUT with only the supplied fields. + * + * Only the supplied $changes are sent; all other fields remain unchanged + * on the server. 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. + * + * $changes may be: + * - A plain `array`: only non-null values are sent. + * - A {@see 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. + * + * @param array|object $changes Fields to update. + * @return T + */ + public function patch(int $id, array|object $changes): DTOInterface; +} diff --git a/src/Core/Contracts/RequestHandlerInterface.php b/src/Core/Contracts/RequestHandlerInterface.php new file mode 100644 index 0000000..0b594d6 --- /dev/null +++ b/src/Core/Contracts/RequestHandlerInterface.php @@ -0,0 +1,111 @@ + $options PSR-18 client options (e.g. `['json' => [...]]`). + * @return array JSON-decoded response body. + * @throws \ZammadAPIClient\Exceptions\AuthenticationException For 401 responses. + * @throws \ZammadAPIClient\Exceptions\NotFoundException For 404 responses. + * @throws \ZammadAPIClient\Exceptions\ValidationException For 422 responses. + * @throws \ZammadAPIClient\Exceptions\RateLimitException For 429 responses (after retries). + * @throws \ZammadAPIClient\Exceptions\ServerErrorException For 5xx responses. + * @throws \ZammadAPIClient\Exceptions\NetworkException On transport-level failures. + */ + public function request( + string $method, + string $uri, + array $options = [], + ): array; + + /** + * Performs a GET request and returns the raw response body as a string. + * + * Use this instead of {@see self::get()} when the endpoint returns binary + * data (e.g. ticket attachment content) that cannot be JSON-decoded. + * + * @param array $query URL query parameters. + * @throws \ZammadAPIClient\Exceptions\NetworkException On transport failure. + */ + public function getRaw(string $uri, array $query = []): string; + + /** + * Performs a GET request and returns the decoded JSON body. + * + * @param array $query URL query parameters appended to the URI. + * @return array + */ + public function get(string $uri, array $query = []): array; + + /** + * Performs a POST request with a JSON body and returns the decoded response. + * + * @param array $body Request payload; serialised to JSON automatically. + * @return array + */ + public function post(string $uri, array $body = []): array; + + /** + * Performs a PUT request with a JSON body and returns the decoded response. + * + * @param array $body Request payload; serialised to JSON automatically. + * @return array + */ + public function put(string $uri, array $body = []): array; + + /** + * Performs a DELETE request and returns the decoded JSON body. + * + * Zammad's DELETE endpoints return the deleted resource's data, which + * callers may use to confirm the operation. + * + * @return array + */ + public function delete(string $uri): array; + + /** + * Returns the raw PSR-7 response of the most recent request, or null. + * + * Useful for inspecting response headers after a repository call. + */ + public function getLastResponse(): ?ResponseInterface; + + /** + * Sets or clears the user identifier for API impersonation. + * + * When non-null the value is forwarded as the `From` HTTP header, + * causing Zammad to execute the request as the given user. + * The value may be a user ID, login, or email. Pass null to + * remove impersonation. + * + * @see https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users + */ + public function setOnBehalfOfUser(int|string|null $userId): void; + + public function getOnBehalfOfUser(): int|string|null; +} diff --git a/src/Core/DtoHydrator.php b/src/Core/DtoHydrator.php new file mode 100644 index 0000000..93144a9 --- /dev/null +++ b/src/Core/DtoHydrator.php @@ -0,0 +1,137 @@ +> + */ + 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/HttpPageFetcher.php b/src/Core/HttpPageFetcher.php new file mode 100644 index 0000000..f692fb1 --- /dev/null +++ b/src/Core/HttpPageFetcher.php @@ -0,0 +1,112 @@ + + */ +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 + { + /** @phpstan-ignore cast.int */ + $total = (int) ($data['total_count'] ?? 0); + $items = $data['records'] ?? []; + + return [ + 'items' => is_array($items) ? $this->hydrateItems($items) : [], + 'total_count' => $total, + ]; + } + + /** + * @param array $data + * @return array{items: list, total_count: null} + */ + private function extractIndexResults(array $data): array + { + $listKey = $this->listKey ?? $this->inferListKey(); + + return [ + 'items' => $this->hydrateItems(ResponseParser::extractItems($data, $listKey)), + 'total_count' => null, + ]; + } + + /** + * @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/PaginatedList.php b/src/Core/PaginatedList.php new file mode 100644 index 0000000..4ec4ee5 --- /dev/null +++ b/src/Core/PaginatedList.php @@ -0,0 +1,209 @@ + + * @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 + { + 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/RepositoryRegistry.php b/src/Core/RepositoryRegistry.php new file mode 100644 index 0000000..45fbfbd --- /dev/null +++ b/src/Core/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/RequestHandler.php b/src/Core/RequestHandler.php new file mode 100644 index 0000000..cb19ffc --- /dev/null +++ b/src/Core/RequestHandler.php @@ -0,0 +1,340 @@ +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; + } + + /** + * Activates or deactivates API-level user impersonation. + * + * When $userId is non-null it is forwarded as the `From` HTTP + * header on every subsequent request, causing Zammad to execute actions + * as the given agent. The value may be a user ID, login, or email. + * Pass null to disable impersonation. + * + * @see https://docs.zammad.org/en/latest/api/intro.html#actions-on-behalf-of-other-users + */ + public function setOnBehalfOfUser(int|string|null $userId): void + { + $this->onBehalfOfUser = $userId; + } + + public function getOnBehalfOfUser(): int|string|null + { + return $this->onBehalfOfUser; + } + + /** + * 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. + */ + public function getRaw(string $uri, array $query = []): string + { + if (!empty($query)) { + $uri .= '?' . http_build_query($query); + } + + return (string) $this->dispatch('GET', $uri, [])->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}"); + + if ($this->onBehalfOfUser !== null) { + $headers = $options['headers'] ?? []; + $options['headers'] = is_array($headers) ? $headers : []; + $options['headers']['From'] = (string) $this->onBehalfOfUser; + } + + 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/Resource.php b/src/Core/Resource.php new file mode 100644 index 0000000..7d94af8 --- /dev/null +++ b/src/Core/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/ResponseParser.php b/src/Core/ResponseParser.php new file mode 100644 index 0000000..599f588 --- /dev/null +++ b/src/Core/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/RetryAfterMiddleware.php b/src/Core/RetryAfterMiddleware.php new file mode 100644 index 0000000..dcae9be --- /dev/null +++ b/src/Core/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/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..bf7d74f --- /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/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/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..69c9edb --- /dev/null +++ b/src/Endpoints/Groups/GroupRepository.php @@ -0,0 +1,30 @@ + + */ +final class GroupRepository extends AbstractRepository implements DeletableInterface +{ + protected function getListKey(): string + { + return 'groups'; + } + + 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 +{ + protected function getListKey(): string + { + return 'links'; + } + + /** + * 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..4a82e9e --- /dev/null +++ b/src/Endpoints/Organizations/OrganizationRepository.php @@ -0,0 +1,51 @@ + + */ +final class OrganizationRepository extends AbstractRepository implements DeletableInterface +{ + /** + * Returns 'organizations' — the JSON array key in Zammad's paginated organization list response. + */ + protected function getListKey(): string + { + return 'organizations'; + } + + /** + * 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 +{ + /** + * Returns 'tags' — the JSON array key in Zammad's tag list response. + */ + protected function getListKey(): string + { + return 'tags'; + } + + /** + * 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 +{ + protected function getListKey(): string + { + return 'text_modules'; + } + + 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..eeff4ee --- /dev/null +++ b/src/Endpoints/TicketArticles/TicketArticleRepository.php @@ -0,0 +1,80 @@ + + */ +final class TicketArticleRepository extends AbstractRepository +{ + /** + * Returns 'ticket_articles' — the JSON array key in Zammad's article list response. + * + * Zammad wraps paginated results in `{"ticket_articles": [...], "assets": {...}}`. + */ + protected function getListKey(): string + { + return 'ticket_articles'; + } + + /** + * 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 +{ + /** + * Returns 'ticket_priorities' — the JSON array key in Zammad's paginated priority list response. + */ + protected function getListKey(): string + { + return 'ticket_priorities'; + } +} 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 +{ + /** + * Returns 'ticket_states' — the JSON array key in Zammad's paginated state list response. + */ + protected function getListKey(): string + { + return 'ticket_states'; + } +} diff --git a/src/Endpoints/Tickets/TicketDTO.php b/src/Endpoints/Tickets/TicketDTO.php new file mode 100644 index 0000000..c3ec0ff --- /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..bd64be9 --- /dev/null +++ b/src/Endpoints/Tickets/TicketRepository.php @@ -0,0 +1,56 @@ + + */ +final class TicketRepository extends AbstractRepository implements DeletableInterface +{ + /** + * Returns 'tickets' — the JSON array key in Zammad's paginated ticket list response. + * + * Zammad wraps results in `{"tickets": [...], "assets": {...}}`; this key + * tells {@see \ZammadAPIClient\Core\AbstractRepository::extractItems()} where to find the items. + */ + protected function getListKey(): string + { + return 'tickets'; + } + + /** + * 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..b6527a5 --- /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\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..8b31fab --- /dev/null +++ b/src/Endpoints/Users/UserRepository.php @@ -0,0 +1,54 @@ + + */ +final class UserRepository extends AbstractRepository implements DeletableInterface +{ + /** + * Returns 'users' — the JSON array key in Zammad's paginated user list response. + */ + protected function getListKey(): string + { + return 'users'; + } + + /** + * 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 @@ + - */ - -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..06d15d6 --- /dev/null +++ b/src/ZammadClient.php @@ -0,0 +1,298 @@ + */ + private array $repos = []; + + public function __construct( + private RequestHandlerInterface $handler, + ) { + } + + /** + * Creates a client with token-based authentication (preferred). + * + * $client = ZammadClient::withToken('https://zammad.example', 'your-token'); + */ + public static function withToken( + string $url, + string $token, + ?ConnectionConfig $config = null, + ): self { + $config ??= new ConnectionConfig(); + + return self::createClient($url, $config, "Token token={$token}"); + } + + /** + * Creates a client with OAuth2 Bearer token authentication. + * + * $client = ZammadClient::withOAuth2('https://zammad.example', 'your-oauth-token'); + */ + public static function withOAuth2( + string $url, + string $token, + ?ConnectionConfig $config = null, + ): self { + $config ??= new ConnectionConfig(); + + return self::createClient($url, $config, "Bearer {$token}"); + } + + /** + * Creates a client with basic HTTP authentication. + * + * $client = ZammadClient::withBasicAuth('https://zammad.example', 'admin@example.com', 'test'); + */ + public static function withBasicAuth( + string $url, + string $user, + string $pass, + ?ConnectionConfig $config = null, + ): self { + $config ??= new ConnectionConfig(); + + return self::createClient($url, $config, 'Basic ' . base64_encode("{$user}:{$pass}")); + } + + /** + * Creates a client with a pre-configured PSR-18 HTTP client and PSR-17 factory. + * + * Use this when you need a non-Guzzle transport (e.g. Symfony HttpClient) + * or custom middleware. The caller is responsible for setting auth headers + * (Authorization, User-Agent) on the PSR-18 client. + * + * $client = ZammadClient::withClient( + * $mySymfonyHttpClient, + * $myPsr17Factory, + * 'https://zammad.example', + * ); + */ + public static function withClient( + ClientInterface $httpClient, + RequestFactoryInterface $requestFactory, + string $url, + ?ConnectionConfig $config = null, + ): self { + if (!$requestFactory instanceof StreamFactoryInterface) { + throw new InvalidArgumentException( + 'The factory must implement both RequestFactoryInterface and StreamFactoryInterface.', + ); + } + + $config ??= new ConnectionConfig(); + + $url = self::normalizeUrl($url); + + $handler = new RequestHandler( + $httpClient, + $requestFactory, + $url, + logger: $config->logger ?? new NullLogger(), + maxRetries: $config->maxRetries, + ); + + return new self($handler); + } + + /** + * Returns the underlying PSR-18 request handler for raw API access. + * + * Use this escape hatch when you need to call an endpoint that has no + * dedicated repository (e.g. ticket deletion via `$client->getHandler()->delete('tickets/1')`). + */ + public function getHandler(): RequestHandlerInterface + { + return $this->handler; + } + + private static function normalizeUrl(string $url): string + { + $url = rtrim($url, '/'); + + if (!str_contains($url, '/api/')) { + $url .= '/api/v1'; + } + + return $url; + } + + private static function createClient(string $url, ConnectionConfig $config, string $authHeader): self + { + $url = self::normalizeUrl($url); + + $headers = [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => $authHeader, + ]; + + $httpClient = new GuzzleClient([ + 'headers' => $headers, + 'verify' => $config->verifySsl, + 'timeout' => $config->timeout, + 'connect_timeout' => $config->connectTimeout, + 'allow_redirects' => false, + ]); + + $handler = new RequestHandler( + $httpClient, + new HttpFactory(), + $url, + logger: $config->logger ?? new NullLogger(), + maxRetries: $config->maxRetries, + ); + + return new self($handler); + } + + /** + * Activates API-level impersonation for all subsequent requests. + * + * Forwards the identifier as `From` header. May be a user ID, login, + * or email. The header stays active until unsetOnBehalfOfUser() is called. + */ + public function setOnBehalfOfUser(int|string|null $userId): void + { + $this->handler->setOnBehalfOfUser($userId); + } + + public function unsetOnBehalfOfUser(): void + { + $this->handler->setOnBehalfOfUser(null); + } + + /** + * Executes a closure with a temporary impersonation header. + * + * Ruby-style: client.perform_on_behalf_of(user_id) { ... } + * The header is set before the callback and restored afterwards. + */ + public function performOnBehalfOf(int|string $userId, callable $callback): mixed + { + $previous = $this->handler->getOnBehalfOfUser(); + + $this->handler->setOnBehalfOfUser($userId); + + try { + return $callback($this); + } finally { + $this->handler->setOnBehalfOfUser($previous); + } + } + + /** + * Ruby-style resource accessor: $client->ticket()->find(1) + * + * Maps the method name to a repository using RepositoryRegistry + * via DTO class name lookup. Example: + * ticket() → TicketRepository + * user() → UserRepository + * group() → GroupRepository + * ticket_article() → TicketArticleRepository + * + * @deprecated Use {@see self::repo()} with an explicit repository class + * (e.g. `$client->repo(TicketRepository::class)`) for type-safe, + * IDE-friendly resource access. The magic method will be removed + * in v4.0. + * + * @param array $args + */ + public function __call(string $name, array $args): AbstractRepository + { + trigger_error( + sprintf( + 'Magic resource accessor $client->%s() is deprecated. Use $client->repo(Repository::class) instead.', + $name, + ), + E_USER_DEPRECATED, + ); + + $class = self::resolveAlias($name); + + if ($class !== null) { + return $this->repo($class); + } + + throw new InvalidArgumentException("Unknown resource: {$name}"); + } + + /** @return array> */ + private static function aliasMap(): array + { + return [ + 'ticket' => \ZammadAPIClient\Endpoints\Tickets\TicketRepository::class, + 'user' => \ZammadAPIClient\Endpoints\Users\UserRepository::class, + 'organization' => \ZammadAPIClient\Endpoints\Organizations\OrganizationRepository::class, + 'group' => \ZammadAPIClient\Endpoints\Groups\GroupRepository::class, + 'ticket_article' => \ZammadAPIClient\Endpoints\TicketArticles\TicketArticleRepository::class, + 'ticket_state' => \ZammadAPIClient\Endpoints\TicketStates\TicketStateRepository::class, + 'ticket_priority' => \ZammadAPIClient\Endpoints\TicketPriorities\TicketPriorityRepository::class, + 'tag' => \ZammadAPIClient\Endpoints\Tags\TagRepository::class, + 'text_module' => \ZammadAPIClient\Endpoints\TextModules\TextModuleRepository::class, + 'link' => \ZammadAPIClient\Endpoints\Links\LinkRepository::class, + ]; + } + + /** + * @return ?class-string + */ + private static function resolveAlias(string $name): ?string + { + return self::aliasMap()[$name] ?? null; + } + + /** + * 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 + { + $definition = RepositoryRegistry::definition($repositoryClass); + + /** @var T */ + return $this->repository($repositoryClass, $definition['path'], $definition['dto']); + } + + /** + * @template T of AbstractRepository + * @param class-string $repoClass + * @param class-string $dtoClass + * @return T + */ + private function repository(string $repoClass, string $path, string $dtoClass): object + { + if (!isset($this->repos[$repoClass])) { + $this->repos[$repoClass] = new $repoClass($this->handler, $path, $dtoClass); + } + + /** @var T $repo */ + $repo = $this->repos[$repoClass]; + + return $repo; + } +} 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..ccbbca9 --- /dev/null +++ b/test/Integration/Traits/CreatesZammadClient.php @@ -0,0 +1,39 @@ +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..f250f53 --- /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..fdb51af --- /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']]]); + + $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::assertNull($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/PaginatedListTest.php b/test/Unit/Core/PaginatedListTest.php new file mode 100644 index 0000000..f814cf1 --- /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..b39ed4d --- /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..a62278d --- /dev/null +++ b/test/Unit/Core/RequestHandlerTest.php @@ -0,0 +1,267 @@ +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 testOnBehalfOfHeaderIsApplied(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + $this->handler->setOnBehalfOfUser(7); + + $this->handler->get('tickets'); + + self::assertNotNull($this->httpClient->lastRequest); + self::assertSame('7', $this->httpClient->lastRequest->getHeaderLine('From')); + } + + public function testOnBehalfOfWithStringLogin(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + $this->handler->setOnBehalfOfUser('agent@example.com'); + + $this->handler->get('tickets'); + + self::assertNotNull($this->httpClient->lastRequest); + self::assertSame('agent@example.com', $this->httpClient->lastRequest->getHeaderLine('From')); + } + + 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 testGetOnBehalfOfUserReturnsSetValue(): void + { + self::assertNull($this->handler->getOnBehalfOfUser()); + + $this->handler->setOnBehalfOfUser(7); + self::assertSame(7, $this->handler->getOnBehalfOfUser()); + + $this->handler->setOnBehalfOfUser('user@example.com'); + self::assertSame('user@example.com', $this->handler->getOnBehalfOfUser()); + + $this->handler->setOnBehalfOfUser(null); + self::assertNull($this->handler->getOnBehalfOfUser()); + } + + 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..815c013 --- /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..e22c4ea --- /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..3827486 --- /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..5e32103 --- /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/Repositories/GroupRepositoryTest.php b/test/Unit/Repositories/GroupRepositoryTest.php new file mode 100644 index 0000000..61396f4 --- /dev/null +++ b/test/Unit/Repositories/GroupRepositoryTest.php @@ -0,0 +1,80 @@ +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..72fa957 --- /dev/null +++ b/test/Unit/ZammadClientTest.php @@ -0,0 +1,185 @@ +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 testCallResolvesTicketRepository(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + $repo = @$client->ticket(); + + self::assertInstanceOf(TicketRepository::class, $repo); + } + + public function testCallResolvesUserRepository(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + $repo = @$client->user(); + + self::assertInstanceOf(UserRepository::class, $repo); + } + + public function testCallMemoizesRepository(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + self::assertSame(@$client->ticket(), @$client->ticket()); + } + + public function testCallThrowsForUnknownResource(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown resource'); + + @$client->nonexistent(); + } + + public function testCallResolvesUnderscoreResources(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $client = new ZammadClient($handler); + + $repo = @$client->ticket_article(); + + self::assertInstanceOf(\ZammadAPIClient\Endpoints\TicketArticles\TicketArticleRepository::class, $repo); + } + + public function testSetOnBehalfOfUserDelegatesToHandler(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $handler->expects('setOnBehalfOfUser')->with(7)->once(); + + $client = new ZammadClient($handler); + $client->setOnBehalfOfUser(7); + } + + public function testUnsetOnBehalfOfUserDelegatesToHandler(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $handler->expects('setOnBehalfOfUser')->with(null)->once(); + + $client = new ZammadClient($handler); + $client->unsetOnBehalfOfUser(); + } + + public function testPerformOnBehalfOfExecutesCallbackAndResets(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $handler->expects('getOnBehalfOfUser')->andReturn(null)->once(); + $handler->expects('setOnBehalfOfUser')->with(7)->once(); + $handler->expects('setOnBehalfOfUser')->with(null)->once(); + + $client = new ZammadClient($handler); + + $result = $client->performOnBehalfOf(7, fn() => 42); + + self::assertSame(42, $result); + } + + public function testPerformOnBehalfOfResetsOnException(): void + { + $handler = Mockery::mock(RequestHandlerInterface::class); + $handler->expects('getOnBehalfOfUser')->andReturn(null)->once(); + $handler->expects('setOnBehalfOfUser')->with(7)->once(); + $handler->expects('setOnBehalfOfUser')->with(null)->once(); + + $client = new ZammadClient($handler); + + $this->expectException(\RuntimeException::class); + + $client->performOnBehalfOf(7, function () { + throw new \RuntimeException('test'); + }); + } + + public function testWithClientUsesInjectedHttpClient(): void + { + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->expects('sendRequest') + ->once() + ->andReturnUsing(function (RequestInterface $request) { + self::assertStringContainsString('tickets', (string) $request->getUri()); + + return new \GuzzleHttp\Psr7\Response(200, [], (string) json_encode([ + 'tickets' => [ + ['id' => 1, 'title' => 'T1', 'group_id' => 1], + ], + ])); + }); + + $client = ZammadClient::withClient( + $httpClient, + new HttpFactory(), + 'https://zammad.example/api/v1', + ); + + $repo = $client->repo(TicketRepository::class); + $tickets = iterator_to_array($repo->all()); + + self::assertCount(1, $tickets); + self::assertSame(1, $tickets[0]->id); + } + + public function testWithClientThrowsWhenFactoryNotStreamFactory(): void + { + $factory = Mockery::mock(RequestFactoryInterface::class); + $httpClient = Mockery::mock(ClientInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('implement both'); + + ZammadClient::withClient( + $httpClient, + $factory, + 'https://zammad.example/api/v1', + ); + } +} 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 13f2770e2bdcaee7fbff1b0dbb459fddb1b0073e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1004 zcmex=zf#Lr{hDoj!nQ06RzP=1v3=9k$46KY&46HyFBM^Hr zO2gSfj2aBgU~wSHXvn|>WD5XsRC^`^3s{_iAqz-@zzHA*=?BvViJ5sNdU>fO3MP66 zdX_+$No@@5Kv^Cjt^s0(6%08Hfec(=BY|$Z&EO1nA;^{F0+1o(>V@n3e}F-d0~jP+ z%#2D5OoEKef{g!S{3Nli=7$jmA(DJ?6nsH|#kX>Duo=8f2KE_o9%~}YXK;@p{B?_ghnW!= zdCY86Ke*Ln4 zf9$ozAKrX8|M1Jzts)nm@9v%UE>|{RO)6!(*J`e{N2WQk&(JZ{n6N?6%`PA??q2TH zi>EYFXVjQRebsohVdL55QtzhRF^pU)W2Y^)@|?)ZE8p}M|GjE8X~jxMjf1OCo?f+m ybE}N4lu71=Wm7)#8BERG`N&Xf&PLItR$sIIzNWJ3r*pcsnwPpw)vycxe-i-x{YDJ{ 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__);