├── .docker ├── Dockerfile └── conf.d │ └── xdebug.ini ├── .github └── workflows │ ├── code_quality.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── composer.json ├── docker-compose.yml ├── examples ├── 01_without_discovery │ ├── .env.dist │ ├── app.php │ ├── bootstrap.php │ └── composer.json └── 02_with_discovery │ ├── .env.dist │ ├── app.php │ ├── bootstrap.php │ └── composer.json ├── infection.json5 ├── phpstan.neon ├── phpunit.xml ├── qodana.yaml ├── src ├── Auth │ └── Api │ │ └── AuthApi.php ├── Exception │ └── MissingArrayKeyException.php ├── Http │ ├── Exception │ │ ├── AccessDeniedException.php │ │ ├── BadRequestException.php │ │ ├── ConflictException.php │ │ ├── HttpException.php │ │ ├── InternalServerErrorException.php │ │ ├── MissingContentTypeHeaderException.php │ │ ├── MultipleContentTypeValuesException.php │ │ ├── NotFoundException.php │ │ ├── UnauthorizedException.php │ │ └── UnsupportedMediaTypeException.php │ ├── Factory │ │ ├── DiscoverableHttpClientFactory.php │ │ ├── DiscoverableRequestFactoryFactory.php │ │ ├── HttpClientFactory.php │ │ ├── HttpClientFactoryInterface.php │ │ ├── MediaTypeResolverFactory.php │ │ ├── MediaTypeResolverFactoryInterface.php │ │ ├── RequestFactoryFactory.php │ │ ├── RequestFactoryFactoryInterface.php │ │ ├── ResponseHandlerFactory.php │ │ ├── ResponseHandlerFactoryInterface.php │ │ ├── SerializerFactory.php │ │ └── SerializerFactoryInterface.php │ ├── HttpClient.php │ ├── HttpClientInterface.php │ ├── MediaTypeResolver.php │ ├── MediaTypeResolverInterface.php │ ├── RequestFactory.php │ ├── RequestFactoryInterface.php │ ├── ResponseHandler.php │ ├── ResponseHandlerInterface.php │ └── Serializer │ │ ├── DeserializeException.php │ │ ├── EncoderInterface.php │ │ ├── EncodingNotSupportedException.php │ │ ├── JsonEncoder.php │ │ ├── MissingEncoderException.php │ │ ├── PassthroughEncoder.php │ │ ├── SerializeException.php │ │ ├── Serializer.php │ │ └── SerializerInterface.php ├── Integrations │ └── Api │ │ └── IntegrationsApi.php ├── TimeTracking │ ├── Api │ │ ├── ActivitiesApi.php │ │ ├── CurrentTrackingApi.php │ │ ├── DevicesApi.php │ │ ├── ReportsApi.php │ │ ├── TagsAndMentionsApi.php │ │ └── TimeEntriesApi.php │ ├── Exception │ │ ├── ActivitiesException.php │ │ ├── DeviceNameTooLongException.php │ │ ├── DeviceNotFoundException.php │ │ ├── DevicesException.php │ │ ├── InactiveDeviceException.php │ │ ├── InvalidColorException.php │ │ ├── InvalidIntegrationException.php │ │ ├── NotSpaceAdminException.php │ │ ├── ThirdPartyIntegrationException.php │ │ └── TooShortTimeEntryException.php │ └── Model │ │ ├── ActiveTimeEntry.php │ │ ├── Activity.php │ │ ├── Device.php │ │ ├── Duration.php │ │ ├── Mention.php │ │ ├── Note.php │ │ ├── ReportTimeEntry.php │ │ ├── Tag.php │ │ └── TimeEntry.php ├── Timeular.php ├── UserProfile │ ├── Api │ │ ├── SpaceApi.php │ │ └── UserApi.php │ └── Model │ │ ├── Me.php │ │ ├── RetiredUser.php │ │ ├── Role.php │ │ ├── Space.php │ │ └── User.php └── Webhooks │ ├── Api │ └── WebhooksApi.php │ ├── Exception │ ├── InvalidEventException.php │ ├── InvalidUrlException.php │ ├── MaximumSubscriptionsReachedException.php │ ├── SubscriptionNotFoundException.php │ └── WebhooksException.php │ └── Model │ ├── Event.php │ └── Subscription.php └── tests └── unit ├── Auth └── Api │ └── AuthApiTest.php ├── Http ├── Exception │ ├── AccessDeniedExceptionTest.php │ ├── BadRequestExceptionTest.php │ ├── ConflictExceptionTest.php │ ├── HttpExceptionTest.php │ ├── MissingContentTypeHeaderExceptionTest.php │ ├── MultipleContentTypeValuesExceptionTest.php │ ├── NotFoundExceptionTest.php │ ├── UnauthorizedExceptionTest.php │ └── UnsupportedMediaTypeExceptionTest.php ├── HttpClientTest.php ├── MediaTypeResolverTest.php ├── RequestFactoryTest.php ├── ResponseHandlerTest.php └── Serializer │ └── SerializerTest.php ├── HttpClientFactory.php ├── Integrations └── Api │ └── IntegrationsApiTest.php ├── RequestFactoryFactory.php ├── TimeTracking ├── Api │ ├── ActivitiesApiTest.php │ ├── CurrentTrackingApiTest.php │ ├── DevicesApiTest.php │ ├── ReportsApiTest.php │ ├── TagsAndMentionsApiTest.php │ └── TimeEntriesApiTest.php └── Model │ ├── ActiveTimeEntryTest.php │ ├── ActivityTest.php │ ├── DeviceTest.php │ ├── DurationTest.php │ ├── MentionTest.php │ ├── NoteTest.php │ ├── ReportTimeEntryTest.php │ ├── TagTest.php │ └── TimeEntryTest.php ├── UserProfile ├── Api │ ├── SpaceApiTest.php │ └── UserApiTest.php └── Model │ ├── MeTest.php │ ├── RetiredUserTest.php │ ├── SpaceTest.php │ └── UserTest.php └── Webhooks ├── Api └── WebhooksApiTest.php └── Model └── SubscriptionTest.php /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=8.3 2 | ARG ALPINE_VERSION=3.19 3 | 4 | FROM php:${PHP_VERSION}-cli-alpine${ALPINE_VERSION} 5 | 6 | ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ 7 | 8 | RUN install-php-extensions xdebug-^3@stable 9 | 10 | COPY .docker/conf.d /usr/local/etc/php/conf.d 11 | RUN cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/conf.d/php.ini 12 | 13 | COPY --from=composer:2 /usr/bin/composer /usr/bin/composer 14 | 15 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 16 | ENV COMPOSER_ALLOW_SUPERUSER=1 17 | ENV PATH="${PATH}:/root/.composer/vendor/bin" 18 | 19 | WORKDIR /srv/app 20 | -------------------------------------------------------------------------------- /.docker/conf.d/xdebug.ini: -------------------------------------------------------------------------------- 1 | [xdebug] 2 | xdebug.mode=develop,debug,coverage 3 | xdebug.client_host=host.docker.internal 4 | xdebug.start_with_request=yes 5 | -------------------------------------------------------------------------------- /.github/workflows/code_quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | qodana: 12 | name: Qodana 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | checks: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | ref: ${{ github.event.pull_request.head.sha }} 22 | fetch-depth: 0 23 | - name: 'Qodana Scan' 24 | uses: JetBrains/qodana-action@v2024.1 25 | env: 26 | QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} 27 | phpstan: 28 | name: PHPStan 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: 8.3 36 | extensions: xdebug 37 | coverage: xdebug 38 | 39 | - name: Get composer cache directory 40 | id: composer-cache 41 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 42 | 43 | - name: Cache composer dependencies 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.composer-cache.outputs.dir }} 47 | key: "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}" 48 | restore-keys: | 49 | "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}-" 50 | "${{ runner.os }}-composer-" 51 | 52 | - name: Install dependencies 53 | run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader 54 | 55 | - name: Analyse PHP Code (PHPStan) 56 | run: composer run-script qa:phpstan 57 | csfixer: 58 | name: PHP CS Fixer 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | 63 | - uses: shivammathur/setup-php@v2 64 | with: 65 | php-version: 8.3 66 | extensions: xdebug 67 | coverage: xdebug 68 | 69 | - name: Get composer cache directory 70 | id: composer-cache 71 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 72 | 73 | - name: Cache composer dependencies 74 | uses: actions/cache@v4 75 | with: 76 | path: ${{ steps.composer-cache.outputs.dir }} 77 | key: "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}" 78 | restore-keys: | 79 | "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}-" 80 | "${{ runner.os }}-composer-" 81 | 82 | - name: Install dependencies 83 | run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader 84 | 85 | - name: Check Coding Style 86 | run: composer run-script cs:check 87 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | unit: 15 | runs-on: ubuntu-latest 16 | 17 | name: Unit 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: 8.3 25 | extensions: xdebug 26 | coverage: xdebug 27 | 28 | - name: Get composer cache directory 29 | id: composer-cache 30 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 31 | 32 | - name: Cache composer dependencies 33 | uses: actions/cache@v4 34 | with: 35 | path: ${{ steps.composer-cache.outputs.dir }} 36 | key: "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}" 37 | restore-keys: | 38 | "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}-" 39 | "${{ runner.os }}-composer-" 40 | 41 | - name: Install dependencies 42 | run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader 43 | 44 | - name: Run unit tests 45 | run: composer run-script tests:unit 46 | mutation: 47 | runs-on: ubuntu-latest 48 | 49 | name: Mutation 50 | 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - uses: shivammathur/setup-php@v2 55 | with: 56 | php-version: 8.3 57 | extensions: xdebug 58 | coverage: xdebug 59 | 60 | - name: Get composer cache directory 61 | id: composer-cache 62 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 63 | 64 | - name: Cache composer dependencies 65 | uses: actions/cache@v4 66 | with: 67 | path: ${{ steps.composer-cache.outputs.dir }} 68 | key: "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}" 69 | restore-keys: | 70 | "${{ runner.os }}-composer-${{ hashFiles('composer.json') }}-" 71 | "${{ runner.os }}-composer-" 72 | 73 | - name: Install dependencies 74 | run: composer update --no-progress --no-suggest --prefer-dist --optimize-autoloader 75 | 76 | - name: Run mutation tests 77 | run: composer run-script tests:mutation 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .cache/ 3 | vendor/ 4 | docker-compose.override.yml 5 | composer.lock 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ->in(__DIR__ . '/tests') 6 | ->in(__DIR__ . '/examples') 7 | ; 8 | 9 | return (new PhpCsFixer\Config()) 10 | ->setCacheFile(__DIR__ . '/.cache/.php-cs-fixer.cache') 11 | ->setRules([ 12 | '@PER-CS' => true, 13 | ]) 14 | ->setFinder($finder) 15 | ; 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | First off, thank you for considering contributing to this package. It is still under development and no stable version was released yet, so tests might be failing or code not working as expected. 4 | 5 | Pull requests are welcome. For major changes, please open an issue first 6 | to discuss what you would like to change. 7 | 8 | Please make sure to update tests as appropriate. 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Krzysztof Winiarski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timeular API 2 | 3 | > Track time and attendance seamlessly in a single time tracking app. With a physical time tracker, automatic time tracking, and other smart methods, you will do it fast and without effort. Rest assured, it’s GDPR and privacy-compliant. 4 | > 5 | > -- [Timeular](https://timeular.com/) 6 | 7 | ## Motivation 8 | 9 | As a Developer\ 10 | To get paid for my work\ 11 | I have to report my time spent on work 12 | 13 | When working on multiple projects, depending on habits, sometimes it's hard to track time spent on given task when context switching occurs frequently. Filling work time in Jira after each task doesn't work for me. Doing this every day isn't for me either. Trying to remember what I had worked on two weeks earlier was tedious. I like gadgets. My colleague showed my his dice. Each side assigned to different project. I immediately felt in love with it. Now, my own dice sits in front of me, and when I see that it's in standby when I'm working, I know that it needs to be flipped on correct side. 14 | 15 | Timeular application is great, but it lacks few features which would improve my workflow greatly. I wanted to create small app fulfilling all my requirements, but it was impossible to find time for it. And here comes [100 commitów](https://100commitow.pl/) - commit to making small changes for 100 days in a row. I didn't find any PHP library to use Timeular API, so I created one. 16 | 17 | ## Installation 18 | 19 | Package can be installed using [Composer](https://getcomposer.org/) by running command 20 | 21 | ```bash 22 | composer require raptek/timeular-api 23 | ``` 24 | 25 | ## Usage 26 | 27 | There is a `Timeular` class which acts as a facade for all API classes that requires `HttpClient` instance. But nothing stops You from using specific API class. 28 | 29 | This package relies on [virtual packages](https://getcomposer.org/doc/04-schema.md#provide) and not specific package(s) providing PSR implementations. If You want to use this library in project which already uses at least one implementation (PSR-7, PSR-17, PSR-18) you can manually configure all required dependencies (as shown in example 01) or You can leverage `php-http/discovery`, which will try to automatically find best installed implementation OR will install it for You, if plugin is enabled (example 02). 30 | 31 | ## License 32 | 33 | [MIT](LICENSE) 34 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you think that you have found a security issue in Timeular API, please do not use the issue tracker and do not post it publicly. Instead, all security issues must be submitted using `Report a vulnerability` that can be found [on Security tab](https://github.com/Raptek/timeular-api/security). 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "raptek/timeular-api", 3 | "description": "Consume Timeular API using PSR compliant HTTP libraries", 4 | "license": [ 5 | "MIT" 6 | ], 7 | "type": "library", 8 | "authors": [ 9 | { 10 | "name": "Krzysztof Winiarski", 11 | "email": "krzysztof.winiarski@raptek.pl" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.3", 16 | "composer-runtime-api": "^2.0", 17 | "php-http/discovery": "^1.19", 18 | "psr/http-client-implementation": "^1.0", 19 | "psr/http-factory-implementation": "^1.0", 20 | "psr/http-message-implementation": "^1.0" 21 | }, 22 | "require-dev": { 23 | "ergebnis/composer-normalize": "^2.42", 24 | "friendsofphp/php-cs-fixer": "^3.56", 25 | "infection/infection": "^0.28", 26 | "phpstan/phpstan": "^1.11", 27 | "phpstan/phpstan-phpunit": "^1.4", 28 | "phpstan/phpstan-strict-rules": "^1.6", 29 | "phpunit/phpunit": "^11.0", 30 | "psr-mock/http-client-implementation": "^1.0", 31 | "psr-mock/http-factory-implementation": "^1.0", 32 | "psr-mock/http-message-implementation": "^1.0", 33 | "symfony/dotenv": "^7.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Timeular\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\Unit\\Timeular\\": "tests/unit/" 43 | } 44 | }, 45 | "config": { 46 | "allow-plugins": { 47 | "ergebnis/composer-normalize": true, 48 | "infection/extension-installer": true, 49 | "php-http/discovery": false 50 | }, 51 | "sort-packages": true 52 | }, 53 | "scripts": { 54 | "cs:check": "vendor/bin/php-cs-fixer fix --dry-run --diff --ansi", 55 | "cs:fix": "vendor/bin/php-cs-fixer fix --diff --ansi", 56 | "qa:phpstan": "vendor/bin/phpstan analyse --ansi", 57 | "tests:mutation": "vendor/bin/infection --threads=4 --show-mutations", 58 | "tests:unit": "vendor/bin/phpunit" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | build: 4 | context: . 5 | dockerfile: .docker/Dockerfile 6 | volumes: 7 | - './:/srv/app' 8 | -------------------------------------------------------------------------------- /examples/01_without_discovery/.env.dist: -------------------------------------------------------------------------------- 1 | APP_KEY= 2 | APP_SECRET= -------------------------------------------------------------------------------- /examples/01_without_discovery/app.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | create(); 20 | 21 | $timeular = new Timeular($httpClient); 22 | 23 | $user = $timeular->me(); 24 | 25 | var_dump($user); 26 | -------------------------------------------------------------------------------- /examples/01_without_discovery/bootstrap.php: -------------------------------------------------------------------------------- 1 | load(__DIR__ . '/.env'); 10 | -------------------------------------------------------------------------------- /examples/01_without_discovery/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "guzzlehttp/guzzle": "^7.8", 4 | "symfony/dotenv": "^7.1", 5 | "raptek/timeular-api": "dev-main" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/02_with_discovery/.env.dist: -------------------------------------------------------------------------------- 1 | APP_KEY= 2 | APP_SECRET= -------------------------------------------------------------------------------- /examples/02_with_discovery/app.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | create(); 13 | 14 | $timeular = new Timeular($httpClient); 15 | 16 | $user = $timeular->me(); 17 | 18 | var_dump($user); 19 | -------------------------------------------------------------------------------- /examples/02_with_discovery/bootstrap.php: -------------------------------------------------------------------------------- 1 | load(__DIR__ . '/.env'); 10 | -------------------------------------------------------------------------------- /examples/02_with_discovery/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "symfony/dotenv": "^7.1", 4 | "raptek/timeular-api": "dev-main" 5 | }, 6 | "config": { 7 | "allow-plugins": { 8 | "php-http/discovery": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "mutators": { 9 | "@default": true 10 | } 11 | } -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-strict-rules/rules.neon 3 | - vendor/phpstan/phpstan-phpunit/extension.neon 4 | 5 | parameters: 6 | level: 3 7 | paths: 8 | - src 9 | - tests 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | tests/unit 27 | 28 | 29 | 30 | 31 | 32 | src 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | version: "1.0" 2 | 3 | profile: 4 | name: qodana.recommended 5 | 6 | exclude: 7 | - name: All 8 | paths: 9 | - examples 10 | - name: PhpFullyQualifiedNameUsageInspection 11 | 12 | php: 13 | version: 8.3 14 | 15 | linter: jetbrains/qodana-php:latest 16 | -------------------------------------------------------------------------------- /src/Auth/Api/AuthApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 21 | 'POST', 22 | 'developer/sign-in', 23 | [ 24 | 'apiKey' => $apiKey, 25 | 'apiSecret' => $apiSecret, 26 | ], 27 | ); 28 | 29 | return $response['token']; 30 | } 31 | 32 | /** 33 | * @see https://developers.timeular.com/#2cc9aa7c-c235-4b2d-a1b5-7587167bd542 34 | */ 35 | public function fetchApiKey(): string 36 | { 37 | $response = $this->httpClient->request( 38 | 'GET', 39 | 'developer/api-access', 40 | ); 41 | 42 | return $response['apiKey']; 43 | } 44 | 45 | /** 46 | * @see https://developers.timeular.com/#e1db0328-fad2-4679-82c6-c16a89130fce 47 | */ 48 | public function regenerateKeyPair(): array 49 | { 50 | return $this->httpClient->request( 51 | 'POST', 52 | 'developer/api-access', 53 | ); 54 | } 55 | 56 | /** 57 | * @see https://developers.timeular.com/#b2a18382-8f61-4222-bb5d-fa58c2c260a9 58 | */ 59 | public function logout(): void 60 | { 61 | $this->httpClient->request( 62 | 'POST', 63 | 'developer/logout', 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Exception/MissingArrayKeyException.php: -------------------------------------------------------------------------------- 1 | apiKey, 25 | $this->apiSecret, 26 | $this->httpClient, 27 | $this->responseHandlerFactory->create(), 28 | $this->requestFactoryFactory->create(), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Factory/HttpClientFactoryInterface.php: -------------------------------------------------------------------------------- 1 | requestFactory, 24 | $this->streamFactory, 25 | $this->serializerFactory->create(), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Factory/RequestFactoryFactoryInterface.php: -------------------------------------------------------------------------------- 1 | mediaTypeResolverFactory->create(), 21 | $this->serializerFactory->create(), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Http/Factory/ResponseHandlerFactoryInterface.php: -------------------------------------------------------------------------------- 1 | new JsonEncoder(), 21 | 'text/csv' => new PassthroughEncoder(), 22 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => new PassthroughEncoder(), 23 | ] + $this->encoders, 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Http/Factory/SerializerFactoryInterface.php: -------------------------------------------------------------------------------- 1 | requestFactory->create($method, $uri, $payload); 24 | $request = $this->handleAuthorization($request); 25 | $response = $this->httpClient->sendRequest($request); 26 | 27 | return $this->responseHandler->handle($response); 28 | } 29 | 30 | private function handleAuthorization(RequestInterface $request): RequestInterface 31 | { 32 | if ('' !== $request->getHeaderLine('Authorization') || true === str_ends_with($request->getUri()->getPath(), 'developer/sign-in')) { 33 | return $request; 34 | } 35 | 36 | $authApi = new AuthApi($this); 37 | 38 | return $request->withHeader('Authorization', sprintf('Bearer %s', $authApi->signIn($this->apiKey, $this->apiSecret))); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Http/HttpClientInterface.php: -------------------------------------------------------------------------------- 1 | hasHeader('Content-Type')) { 19 | throw MissingContentTypeHeaderException::create(); 20 | } 21 | 22 | $contentTypes = $message->getHeader('Content-Type'); 23 | 24 | if (1 !== \count($contentTypes)) { 25 | throw MultipleContentTypeValuesException::create(); 26 | } 27 | 28 | $contentType = array_pop($contentTypes); 29 | 30 | [$mediaType, ] = explode(';', $contentType); 31 | 32 | return $mediaType; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/MediaTypeResolverInterface.php: -------------------------------------------------------------------------------- 1 | streamFactory->createStream($this->serializer->serialize($payload, 'application/json')); 23 | 24 | return $this->requestFactory 25 | ->createRequest(strtoupper($method), self::BASE_URI . '/' . $uri) 26 | ->withHeader('Content-Type', 'application/json') 27 | ->withBody($body) 28 | ; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Http/RequestFactoryInterface.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 32 | 33 | if (500 === $statusCode) { 34 | // At this moment, this exception doesn't have Content-Type header, so media type can't be resolved. Also, plain text is returned instead of json 35 | throw InternalServerErrorException::withMessage(); 36 | } 37 | 38 | if ( 39 | 401 === $statusCode 40 | // Providing incorrect Bearer token results in 401 without Content-Type and empty string as body 41 | && false === $response->hasHeader('Content-Type') 42 | ) { 43 | throw UnauthorizedException::withMessage(); 44 | } 45 | 46 | if ( 47 | 200 === $statusCode 48 | && false === $response->hasHeader('Content-Type') 49 | && true === $response->hasHeader('Set-Cookie') 50 | ) { 51 | // https://api.timeular.com/api/v3/developer/logout returns 200 without Content-Type and empty string as body 52 | // As we don't need any data from it, we can return anything 53 | // Probably it could be a 204 54 | return ''; 55 | } else { 56 | try { 57 | $mediaType = $this->mediaTypeResolver->getMediaTypeFromMessage($response); 58 | } catch (MissingContentTypeHeaderException | MultipleContentTypeValuesException | \Throwable $exception) { 59 | throw BadRequestException::withMessage($exception->getMessage()); 60 | } 61 | } 62 | 63 | $body = $response->getBody()->getContents(); 64 | 65 | try { 66 | $data = $this->serializer->deserialize($body, $mediaType); 67 | } catch (MissingEncoderException) { 68 | throw UnsupportedMediaTypeException::fromMediaType($mediaType); 69 | } catch (DeserializeException $exception) { 70 | throw BadRequestException::withMessage($exception->getMessage()); 71 | } 72 | 73 | if (400 <= $statusCode) { 74 | throw match ($statusCode) { 75 | 400 => BadRequestException::withMessage($data['message']), 76 | 401 => UnauthorizedException::withMessage($data['message']), // Providing incorrect key/secret results in 401 with proper message, but probably should return 400 77 | 403 => AccessDeniedException::withMessage($data['message']), 78 | 404 => NotFoundException::withMessage($data['message']), 79 | 409 => ConflictException::withMessage($data['message']), 80 | default => HttpException::create($data['message'], $statusCode), 81 | }; 82 | } 83 | 84 | return $data; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Http/ResponseHandlerInterface.php: -------------------------------------------------------------------------------- 1 | getMessage()), previous: $previous); 12 | } 13 | 14 | public static function create(\Throwable $throwable): self 15 | { 16 | return new self($throwable); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Http/Serializer/EncoderInterface.php: -------------------------------------------------------------------------------- 1 | getMessage()), previous: $previous); 12 | } 13 | 14 | public static function create(\Throwable $throwable): self 15 | { 16 | return new self($throwable); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Http/Serializer/Serializer.php: -------------------------------------------------------------------------------- 1 | encoders[$format] ?? null; 15 | 16 | if ($encoder === null) { 17 | throw MissingEncoderException::createForFormat($format); 18 | } 19 | 20 | return $encoder->encode($data); 21 | } 22 | 23 | public function deserialize(string $data, string $format): string|array 24 | { 25 | $encoder = $this->encoders[$format] ?? null; 26 | 27 | if ($encoder === null) { 28 | throw MissingEncoderException::createForFormat($format); 29 | } 30 | 31 | return $encoder->decode($data); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Http/Serializer/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 18 | 'GET', 19 | 'integrations', 20 | ); 21 | 22 | return $response['integrations']; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/ActivitiesApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 28 | 'GET', 29 | 'activities', 30 | ); 31 | 32 | return [ 33 | 'activities' => array_map(static fn(array $activity): Activity => Activity::fromArray($activity), $response['activities']), 34 | 'inactiveActivities' => array_map(static fn(array $activity): Activity => Activity::fromArray($activity), $response['inactiveActivities']), 35 | 'archivedActivities' => array_map(static fn(array $activity): Activity => Activity::fromArray($activity), $response['archivedActivities']), 36 | ]; 37 | } 38 | 39 | /** 40 | * @see https://developers.timeular.com/#591f7ca0-7ec5-4c0e-b0d0-99b6967ce53e 41 | * 42 | * @throws NotSpaceAdminException 43 | */ 44 | public function create(string $name, string $color, string $integration, string $spaceId): Activity 45 | { 46 | try { 47 | $response = $this->httpClient->request( 48 | 'POST', 49 | 'activities', 50 | [ 51 | 'name' => $name, 52 | 'color' => $color, 53 | 'integration' => $integration, 54 | 'spaceId' => $spaceId, 55 | ], 56 | ); 57 | } catch (AccessDeniedException) { 58 | throw NotSpaceAdminException::create(); 59 | } catch (BadRequestException $exception) { 60 | throw match ($exception->getMessage()) { 61 | "Activity Color is not in hexadecimal representation ('#' followed by 3 or 6 characters, eg. '#a0b2f9'" => InvalidColorException::fromColor($color), 62 | "Activity Integration is invalid: valid Activity Integration has from 1 to 50 characters and contains only 'a'-'z', 'A'-'Z', '0'-'9', '-', '_', or '.' (examples: 'jira', 'my.hosted.harvest')" => InvalidIntegrationException::fromIntegration($integration), 63 | 'Third Party Integrations are not allowed in shared spaces - please use your personal space.' => ThirdPartyIntegrationException::create(), 64 | }; 65 | } 66 | 67 | return Activity::fromArray($response); 68 | } 69 | 70 | /** 71 | * @see https://developers.timeular.com/#1ac62610-1bb7-411c-846b-c9690fa3ace5 72 | */ 73 | public function edit(string $id, string $name, string $color): Activity 74 | { 75 | $response = $this->httpClient->request( 76 | 'PATCH', 77 | sprintf('activities/%s', $id), 78 | [ 79 | 'name' => $name, 80 | 'color' => $color, 81 | ], 82 | ); 83 | 84 | return Activity::fromArray($response); 85 | } 86 | 87 | /** 88 | * @see https://developers.timeular.com/#234c5874-2086-4104-bff7-af9b9efeced8 89 | */ 90 | public function archive(string $id): array 91 | { 92 | return $this->httpClient->request( 93 | 'DELETE', 94 | sprintf('activities/%s', $id), 95 | ); 96 | } 97 | 98 | /** 99 | * @see https://developers.timeular.com/#8307c8c6-d1d0-476b-abcf-cf76c3d319c0 100 | */ 101 | public function assign(string $id, int $deviceSide): Activity 102 | { 103 | $response = $this->httpClient->request( 104 | 'POST', 105 | sprintf('activities/%s/device-side/%d', $id, $deviceSide), 106 | ); 107 | 108 | return Activity::fromArray($response); 109 | } 110 | 111 | /** 112 | * @see https://developers.timeular.com/#583e3518-e1df-4a9f-8af7-83efbdd6e79b 113 | */ 114 | public function unassign(string $id, int $deviceSide): Activity 115 | { 116 | $response = $this->httpClient->request( 117 | 'DELETE', 118 | sprintf('activities/%s/device-side/%d', $id, $deviceSide), 119 | ); 120 | 121 | return Activity::fromArray($response); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/CurrentTrackingApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 24 | 'GET', 25 | 'tracking', 26 | ); 27 | 28 | return $response['currentTracking'] ? ActiveTimeEntry::fromArray($response['currentTracking']) : null; 29 | } 30 | 31 | /** 32 | * @see https://developers.timeular.com/#4d1dcf30-125a-48d3-8895-27e611581f50 33 | */ 34 | public function start(string $activityId, \DateTimeInterface $startedAt): ActiveTimeEntry 35 | { 36 | $response = $this->httpClient->request( 37 | 'POST', 38 | sprintf('tracking/%s/start', $activityId), 39 | [ 40 | 'startedAt' => $startedAt->format('Y-m-d\TH:i:s.v'), 41 | ], 42 | ); 43 | 44 | return ActiveTimeEntry::fromArray($response['currentTracking']); 45 | } 46 | 47 | /** 48 | * @see https://developers.timeular.com/#52af0d09-fdd8-4095-81bd-d3319cda2c22 49 | */ 50 | public function edit(string $activityId, \DateTimeInterface|null $startedAt = null, string|null $note = null): ActiveTimeEntry 51 | { 52 | $payload = [ 53 | 'note' => null !== $note ? ['text' => $note] : null, 54 | 'activity' => $activityId, 55 | ]; 56 | 57 | if (null !== $startedAt) { 58 | $payload['startedAt'] = $startedAt->format('Y-m-d\TH:i:s.v'); 59 | } 60 | 61 | $response = $this->httpClient->request( 62 | 'PATCH', 63 | 'tracking', 64 | $payload, 65 | ); 66 | 67 | return ActiveTimeEntry::fromArray($response['currentTracking']); 68 | } 69 | 70 | /** 71 | * @see https://developers.timeular.com/#329c8b25-a27f-41f9-bdd6-8db04627f0ea 72 | */ 73 | public function stop(\DateTimeInterface $stoppedAt): TimeEntry 74 | { 75 | $response = $this->httpClient->request( 76 | 'POST', 77 | 'tracking/stop', 78 | [ 79 | 'stoppedAt' => $stoppedAt->format('Y-m-d\TH:i:s.v'), 80 | ], 81 | ); 82 | 83 | if (array_key_exists('error', $response) && $response['error']['code'] === '00400100001') { 84 | throw new TooShortTimeEntryException(); 85 | } 86 | 87 | return TimeEntry::fromArray($response['createdTimeEntry']); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/DevicesApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 28 | 'GET', 29 | 'devices', 30 | ); 31 | 32 | return array_map(static fn(array $device): Device => Device::fromArray($device), $response['devices']); 33 | } 34 | 35 | /** 36 | * @see https://developers.timeular.com/#2d3946a1-d112-443b-8058-0b27f3fde396 37 | */ 38 | public function activate(string $serial): Device 39 | { 40 | $response = $this->httpClient->request( 41 | 'POST', 42 | sprintf('devices/%s/activate', $serial), 43 | ); 44 | 45 | return Device::fromArray($response); 46 | } 47 | 48 | /** 49 | * @see https://developers.timeular.com/#59928e50-d695-4118-8d71-13079f4ae9d9 50 | * 51 | * @throws DeviceNotFoundException 52 | * @throws InactiveDeviceException 53 | */ 54 | public function deactivate(string $serial): Device 55 | { 56 | try { 57 | $response = $this->httpClient->request( 58 | 'POST', 59 | sprintf('devices/%s/deactivate', $serial), 60 | ); 61 | } catch (NotFoundException) { 62 | throw DeviceNotFoundException::fromSerial($serial); 63 | } catch (ConflictException) { 64 | throw InactiveDeviceException::fromSerial($serial); 65 | } 66 | 67 | return Device::fromArray($response); 68 | } 69 | 70 | /** 71 | * @see https://developers.timeular.com/#78ab7505-587f-469a-974f-781647bc4900 72 | * 73 | * @throws DeviceNotFoundException 74 | * @throws DeviceNameTooLongException 75 | */ 76 | public function edit(string $serial, string $name): Device 77 | { 78 | try { 79 | $response = $this->httpClient->request( 80 | 'PATCH', 81 | sprintf('devices/%s', $serial), 82 | [ 83 | 'name' => $name, 84 | ], 85 | ); 86 | } catch (NotFoundException) { 87 | throw DeviceNotFoundException::fromSerial($serial); 88 | } catch (BadRequestException) { 89 | throw DeviceNameTooLongException::fromName($name); 90 | } 91 | 92 | return Device::fromArray($response); 93 | } 94 | 95 | /** 96 | * @see https://developers.timeular.com/#08024987-8f56-41d4-8653-97cbf1202809 97 | * 98 | * @throws DeviceNotFoundException 99 | */ 100 | public function forget(string $serial): void 101 | { 102 | try { 103 | $this->httpClient->request( 104 | 'DELETE', 105 | sprintf('devices/%s', $serial), 106 | ); 107 | } catch (NotFoundException) { 108 | throw DeviceNotFoundException::fromSerial($serial); 109 | } 110 | } 111 | 112 | /** 113 | * @see https://developers.timeular.com/#985dae45-b3db-4993-a4b1-5847044388bd 114 | * 115 | * @throws DeviceNotFoundException 116 | */ 117 | public function disable(string $serial): Device 118 | { 119 | try { 120 | $response = $this->httpClient->request( 121 | 'POST', 122 | sprintf('devices/%s/disable', $serial), 123 | ); 124 | } catch (NotFoundException) { 125 | throw DeviceNotFoundException::fromSerial($serial); 126 | } 127 | 128 | return Device::fromArray($response); 129 | } 130 | 131 | /** 132 | * @see https://developers.timeular.com/#96f1eb5b-5aa6-43eb-9176-fd8b7bd5b16f 133 | * 134 | * @throws DeviceNotFoundException 135 | */ 136 | public function enable(string $serial): Device 137 | { 138 | try { 139 | $response = $this->httpClient->request( 140 | 'POST', 141 | sprintf('devices/%s/enable', $serial), 142 | ); 143 | } catch (NotFoundException) { 144 | throw DeviceNotFoundException::fromSerial($serial); 145 | } 146 | 147 | return Device::fromArray($response); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/ReportsApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 23 | 'GET', 24 | sprintf('report/data/%s/%s', $startedAt->format(Duration::FORMAT), $stoppedAt->format(Duration::FORMAT)), 25 | ); 26 | 27 | return array_map(static fn(array $timeEntry): ReportTimeEntry => ReportTimeEntry::fromArray($timeEntry), $response['timeEntries']); 28 | } 29 | 30 | /** 31 | * @see https://developers.timeular.com/#f9bed9f5-6fbe-4062-9881-76b117430eb2 32 | */ 33 | public function generateReport(\DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, \DateTimeZone $timezone, string|null $activityId = null, string|null $noteQuery = null, string|null $fileType = 'csv'): string 34 | { 35 | $queryData = [ 36 | 'timezone' => $timezone->getName(), 37 | 'activityId' => $activityId, 38 | 'noteQuery' => $noteQuery, 39 | 'fileType' => $fileType, 40 | ]; 41 | 42 | $query = http_build_query(array_filter($queryData)); 43 | 44 | return $this->httpClient->request( 45 | 'GET', 46 | sprintf('report/%s/%s?%s', $startedAt->format(Duration::FORMAT), $stoppedAt->format(Duration::FORMAT), $query), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/TagsAndMentionsApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 23 | 'GET', 24 | 'tags-and-mentions', 25 | ); 26 | 27 | return [ 28 | 'tags' => array_map(static fn(array $tagData): Tag => Tag::fromArray($tagData), $response['tags']), 29 | 'mentions' => array_map(static fn(array $mentionData): Mention => Mention::fromArray($mentionData), $response['mentions']), 30 | ]; 31 | } 32 | 33 | /** 34 | * @see https://developers.timeular.com/#d62392ca-2eb2-40c9-8d14-834ba581122e 35 | */ 36 | public function createTag( 37 | string $key, 38 | string $label, 39 | string $scope, 40 | string $spaceId, 41 | ): Tag { 42 | $response = $this->httpClient->request( 43 | 'POST', 44 | 'tags', 45 | [ 46 | 'key' => $key, 47 | 'label' => $label, 48 | 'scope' => $scope, 49 | 'spaceId' => $spaceId, 50 | ], 51 | ); 52 | 53 | return Tag::fromArray($response); 54 | } 55 | 56 | /** 57 | * @see https://developers.timeular.com/#34edd1e9-c5fd-47f3-83a6-bc16e6409d11 58 | */ 59 | public function updateTag( 60 | string $id, 61 | string $label, 62 | ): Tag { 63 | $response = $this->httpClient->request( 64 | 'PATCH', 65 | sprintf('tags/%s', $id), 66 | [ 67 | 'label' => $label, 68 | ], 69 | ); 70 | 71 | return Tag::fromArray($response); 72 | } 73 | 74 | /** 75 | * @see https://developers.timeular.com/#c930c6f5-e825-413e-b430-434a05e96e6c 76 | */ 77 | public function deleteTag( 78 | string $id, 79 | ): array { 80 | return $this->httpClient->request( 81 | 'DELETE', 82 | sprintf('tags/%s', $id), 83 | ); 84 | } 85 | 86 | /** 87 | * @see https://developers.timeular.com/#b0de30da-39f4-4d21-b5d5-09e79940c820 88 | */ 89 | public function createMention( 90 | string $key, 91 | string $label, 92 | string $scope, 93 | string $spaceId, 94 | ): Mention { 95 | $response = $this->httpClient->request( 96 | 'POST', 97 | 'mentions', 98 | [ 99 | 'key' => $key, 100 | 'label' => $label, 101 | 'scope' => $scope, 102 | 'spaceId' => $spaceId, 103 | ], 104 | ); 105 | 106 | return Mention::fromArray($response); 107 | } 108 | 109 | /** 110 | * @see https://developers.timeular.com/#b00ccf63-701c-471f-abd1-31735f6224d3 111 | */ 112 | public function updateMention( 113 | string $id, 114 | string $label, 115 | ): Mention { 116 | $response = $this->httpClient->request( 117 | 'PATCH', 118 | sprintf('mentions/%s', $id), 119 | [ 120 | 'label' => $label, 121 | ], 122 | ); 123 | 124 | return Mention::fromArray($response); 125 | } 126 | 127 | /** 128 | * @see https://developers.timeular.com/#a7e6b2fa-d879-4368-a4f1-eea14808eef8 129 | */ 130 | public function deleteMention( 131 | string $id, 132 | ): array { 133 | return $this->httpClient->request( 134 | 'DELETE', 135 | sprintf('mentions/%s', $id), 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/TimeTracking/Api/TimeEntriesApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 23 | 'GET', 24 | sprintf('time-entries/%s/%s', $startedAt->format(Duration::FORMAT), $stoppedAt->format(Duration::FORMAT)), 25 | ); 26 | 27 | return array_map(static fn(array $timeEntry): TimeEntry => TimeEntry::fromArray($timeEntry), $response['timeEntries']); 28 | } 29 | 30 | /** 31 | * @see https://developers.timeular.com/#e66a9e5a-1035-4522-a9fc-5df5a5a05ef7 32 | */ 33 | public function create(string $activityId, \DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, string|null $note): TimeEntry 34 | { 35 | $response = $this->httpClient->request( 36 | 'POST', 37 | 'time-entries', 38 | [ 39 | 'activityId' => $activityId, 40 | 'startedAt' => $startedAt->format(Duration::FORMAT), 41 | 'stoppedAt' => $stoppedAt->format(Duration::FORMAT), 42 | 'note' => null !== $note ? ['text' => $note] : null, 43 | ], 44 | ); 45 | 46 | return TimeEntry::fromArray($response); 47 | } 48 | 49 | /** 50 | * @see https://developers.timeular.com/#b4c0569e-a8a7-4c11-9b82-d091bf656812 51 | */ 52 | public function findById(string $id): TimeEntry 53 | { 54 | $response = $this->httpClient->request( 55 | 'GET', 56 | sprintf('time-entries/%s', $id), 57 | ); 58 | 59 | return TimeEntry::fromArray($response); 60 | } 61 | 62 | /** 63 | * @see https://developers.timeular.com/#18d45e78-35f7-4dc2-a6c4-edb2405014ed 64 | */ 65 | public function edit(string $id, string $activityId, \DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, string|null $note): TimeEntry 66 | { 67 | $response = $this->httpClient->request( 68 | 'PATCH', 69 | sprintf('time-entries/%s', $id), 70 | [ 71 | 'activityId' => $activityId, 72 | 'startedAt' => $startedAt->format(Duration::FORMAT), 73 | 'stoppedAt' => $stoppedAt->format(Duration::FORMAT), 74 | 'note' => null !== $note ? ['text' => $note] : null, 75 | ], 76 | ); 77 | 78 | return TimeEntry::fromArray($response); 79 | } 80 | 81 | /** 82 | * @see https://developers.timeular.com/#a987147c-7c11-4fdc-9ca5-7b03e0999199 83 | */ 84 | public function delete(string $id): array 85 | { 86 | return $this->httpClient->request( 87 | 'DELETE', 88 | sprintf('time-entries/%s', $id), 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/TimeTracking/Exception/ActivitiesException.php: -------------------------------------------------------------------------------- 1 | $this->id, 43 | 'activityId' => $this->activityId, 44 | 'startedAt' => $this->startedAt->format(Duration::FORMAT), 45 | 'note' => $this->note->toArray(), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Activity.php: -------------------------------------------------------------------------------- 1 | $this->id, 49 | 'name' => $this->name, 50 | 'color' => $this->color, 51 | 'integration' => $this->integration, 52 | 'spaceId' => $this->spaceId, 53 | 'deviceSide' => $this->deviceSide, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Device.php: -------------------------------------------------------------------------------- 1 | $this->serial, 39 | 'name' => $this->name, 40 | 'active' => $this->active, 41 | 'disabled' => $this->disabled, 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Duration.php: -------------------------------------------------------------------------------- 1 | $this->startedAt->format(Duration::FORMAT), 35 | 'stoppedAt' => $this->stoppedAt->format(Duration::FORMAT), 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Mention.php: -------------------------------------------------------------------------------- 1 | $this->id, 48 | 'key' => $this->key, 49 | 'label' => $this->label, 50 | 'scope' => $this->scope, 51 | 'spaceId' => $this->spaceId, 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Note.php: -------------------------------------------------------------------------------- 1 | Tag::fromArray($tag), $data['tags']); 24 | } catch (\DomainException $exception) { 25 | // @todo throw nice exception 26 | } 27 | } 28 | 29 | $mentions = []; 30 | 31 | if (true === array_key_exists('mentions', $data)) { 32 | try { 33 | $mentions = array_map(static fn(array $mention): Mention => Mention::fromArray($mention), $data['mentions']); 34 | } catch (\DomainException $exception) { 35 | // @todo throw nice exception 36 | } 37 | } 38 | 39 | return new self($text, $tags, $mentions); 40 | } 41 | 42 | public function toArray(): array 43 | { 44 | return [ 45 | 'text' => $this->text, 46 | 'tags' => array_map(static fn(Tag $tag): array => $tag->toArray(), $this->tags), 47 | 'mentions' => array_map(static fn(Mention $mention): array => $mention->toArray(), $this->mentions), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/ReportTimeEntry.php: -------------------------------------------------------------------------------- 1 | $this->id, 48 | 'activity' => $this->activity->toArray(), 49 | 'duration' => $this->duration->toArray(), 50 | 'note' => $this->note->toArray(), 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/Tag.php: -------------------------------------------------------------------------------- 1 | $this->id, 48 | 'key' => $this->key, 49 | 'label' => $this->label, 50 | 'scope' => $this->scope, 51 | 'spaceId' => $this->spaceId, 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/TimeTracking/Model/TimeEntry.php: -------------------------------------------------------------------------------- 1 | $this->id, 48 | 'activityId' => $this->activityId, 49 | 'duration' => $this->duration->toArray(), 50 | 'note' => $this->note->toArray(), 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Timeular.php: -------------------------------------------------------------------------------- 1 | user = new UserProfile\Api\UserApi($httpClient); 34 | $this->space = new UserProfile\Api\SpaceApi($httpClient); 35 | $this->devices = new TimeTracking\Api\DevicesApi($httpClient); 36 | $this->tagsAndMentions = new TimeTracking\Api\TagsAndMentionsApi($httpClient); 37 | $this->activities = new TimeTracking\Api\ActivitiesApi($httpClient); 38 | $this->currentTracking = new TimeTracking\Api\CurrentTrackingApi($httpClient); 39 | $this->timeEntries = new TimeTracking\Api\TimeEntriesApi($httpClient); 40 | $this->reports = new TimeTracking\Api\ReportsApi($httpClient); 41 | $this->webhooks = new Webhooks\Api\WebhooksApi($httpClient); 42 | $this->integrations = new Integrations\Api\IntegrationsApi($httpClient); 43 | } 44 | 45 | public function me(): Me 46 | { 47 | return $this->user->me(); 48 | } 49 | 50 | public function devices(): array 51 | { 52 | return $this->devices->list(); 53 | } 54 | 55 | public function activateDevice(string $serial): Device 56 | { 57 | return $this->devices->activate($serial); 58 | } 59 | 60 | public function deactivateDevice(string $serial): Device 61 | { 62 | return $this->devices->deactivate($serial); 63 | } 64 | 65 | public function forgetDevice(string $serial): void 66 | { 67 | $this->devices->forget($serial); 68 | } 69 | 70 | public function disableDevice(string $serial): Device 71 | { 72 | return $this->devices->disable($serial); 73 | } 74 | 75 | public function enableDevice(string $serial): Device 76 | { 77 | return $this->devices->enable($serial); 78 | } 79 | 80 | public function editDevice(string $serial, string $name): Device 81 | { 82 | return $this->devices->edit($serial, $name); 83 | } 84 | 85 | public function spacesWithMembers(): array 86 | { 87 | return $this->space->spacesWithMembers(); 88 | } 89 | 90 | public function tagsAndMentions(): array 91 | { 92 | return $this->tagsAndMentions->tagsAndMentions(); 93 | } 94 | 95 | public function createTag( 96 | string $key, 97 | string $label, 98 | string $scope, 99 | string $spaceId, 100 | ): Tag { 101 | return $this->tagsAndMentions->createTag($key, $label, $scope, $spaceId); 102 | } 103 | 104 | public function updateTag( 105 | string $id, 106 | string $label, 107 | ): Tag { 108 | return $this->tagsAndMentions->updateTag($id, $label); 109 | } 110 | 111 | public function deleteTag( 112 | string $id, 113 | ): array { 114 | return $this->tagsAndMentions->deleteTag($id); 115 | } 116 | 117 | public function createMention( 118 | string $key, 119 | string $label, 120 | string $scope, 121 | string $spaceId, 122 | ): Mention { 123 | return $this->tagsAndMentions->createMention($key, $label, $scope, $spaceId); 124 | } 125 | 126 | public function updateMention( 127 | string $id, 128 | string $label, 129 | ): Mention { 130 | return $this->tagsAndMentions->updateMention($id, $label); 131 | } 132 | 133 | public function deleteMention( 134 | string $id, 135 | ): array { 136 | return $this->tagsAndMentions->deleteMention($id); 137 | } 138 | 139 | public function activities(): array 140 | { 141 | return $this->activities->list(); 142 | } 143 | 144 | public function createActivity(string $name, string $color, string $integration, string $spaceId): Activity 145 | { 146 | return $this->activities->create($name, $color, $integration, $spaceId); 147 | } 148 | 149 | public function editActivity(string $id, string $name, string $color): Activity 150 | { 151 | return $this->activities->edit($id, $name, $color); 152 | } 153 | 154 | public function archiveActivity(string $id): array 155 | { 156 | return $this->activities->archive($id); 157 | } 158 | 159 | public function assignActivityToDeviceSide(string $id, int $deviceSide): Activity 160 | { 161 | return $this->activities->assign($id, $deviceSide); 162 | } 163 | 164 | public function unassignActivityFromDeviceSide(string $id, int $deviceSide): Activity 165 | { 166 | return $this->activities->unassign($id, $deviceSide); 167 | } 168 | 169 | public function showCurrentTracking(): ActiveTimeEntry|null 170 | { 171 | return $this->currentTracking->show(); 172 | } 173 | 174 | public function startTracking(string $activityId, \DateTimeInterface $startedAt): ActiveTimeEntry 175 | { 176 | return $this->currentTracking->start($activityId, $startedAt); 177 | } 178 | 179 | public function editTracking(string $activityId, \DateTimeInterface|null $startedAt = null, string|null $note = null): ActiveTimeEntry 180 | { 181 | return $this->currentTracking->edit($activityId, $startedAt, $note); 182 | } 183 | 184 | public function stopTracking(\DateTimeInterface $stoppedAt): TimeEntry 185 | { 186 | return $this->currentTracking->stop($stoppedAt); 187 | } 188 | 189 | public function findTimeEntries(\DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt): array 190 | { 191 | return $this->timeEntries->find($startedAt, $stoppedAt); 192 | } 193 | 194 | public function createTimeEntry(string $activityId, \DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, string|null $note): TimeEntry 195 | { 196 | return $this->timeEntries->create($activityId, $startedAt, $stoppedAt, $note); 197 | } 198 | 199 | public function findTimeEntry(string $id): TimeEntry 200 | { 201 | return $this->timeEntries->findById($id); 202 | } 203 | 204 | public function editTimeEntry(string $id, string $activityId, \DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, string|null $note): TimeEntry 205 | { 206 | return $this->timeEntries->edit($id, $activityId, $startedAt, $stoppedAt, $note); 207 | } 208 | 209 | public function deleteTimeEntry(string $id): array 210 | { 211 | return $this->timeEntries->delete($id); 212 | } 213 | 214 | public function getEntriesInDateRange(\DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt): array 215 | { 216 | return $this->reports->getAllData($startedAt, $stoppedAt); 217 | } 218 | 219 | public function generateReport(\DateTimeInterface $startedAt, \DateTimeInterface $stoppedAt, \DateTimeZone $timezone, string|null $activityId = null, string|null $noteQuery = null, string|null $fileType = 'csv'): string 220 | { 221 | return $this->reports->generateReport($startedAt, $stoppedAt, $timezone, $activityId, $noteQuery, $fileType); 222 | } 223 | 224 | public function listAvailableEvents(): array 225 | { 226 | return $this->webhooks->listAvailableEvents(); 227 | } 228 | 229 | public function subscribeToEvent(string $event, string $targetUrl): Subscription 230 | { 231 | return $this->webhooks->subscribe($event, $targetUrl); 232 | } 233 | 234 | public function unsubscribeFromEvent(string $id): void 235 | { 236 | $this->webhooks->unsubscribe($id); 237 | } 238 | 239 | public function listSubscriptions(): array 240 | { 241 | return $this->webhooks->listSubscriptions(); 242 | } 243 | 244 | public function unsubscribeAllEventsForUser(): void 245 | { 246 | $this->webhooks->unsubscribeAllForUser(); 247 | } 248 | 249 | public function listEnabledIntegrations(): array 250 | { 251 | return $this->integrations->listEnabledIntegrations(); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/UserProfile/Api/SpaceApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 22 | 'GET', 23 | 'space', 24 | ); 25 | 26 | $spaces = []; 27 | 28 | foreach ($response['data'] as $spaceData) { 29 | $spaces[] = Space::fromArray($spaceData); 30 | } 31 | 32 | return $spaces; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/UserProfile/Api/UserApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 22 | 'GET', 23 | 'me', 24 | ); 25 | 26 | return Me::fromArray($response['data']); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/UserProfile/Model/Me.php: -------------------------------------------------------------------------------- 1 | $this->userId, 43 | 'name' => $this->name, 44 | 'email' => $this->email, 45 | 'defaultSpaceId' => $this->defaultSpaceId, 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/UserProfile/Model/RetiredUser.php: -------------------------------------------------------------------------------- 1 | $this->id, 33 | 'name' => $this->name, 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/UserProfile/Model/Role.php: -------------------------------------------------------------------------------- 1 | User::fromArray($memberData), $data['members']); 42 | $retiredMembers = array_map(static fn(array $memberData): RetiredUser => RetiredUser::fromArray($memberData), $data['retiredMembers']); 43 | 44 | return new self($data['id'], $data['name'], $data['default'], $members, $retiredMembers); 45 | } 46 | 47 | public function toArray(): array 48 | { 49 | return [ 50 | 'id' => $this->id, 51 | 'name' => $this->name, 52 | 'default' => $this->default, 53 | 'members' => array_map(static fn(User $user): array => $user->toArray(), $this->members), 54 | 'retiredMembers' => array_map(static fn(RetiredUser $retiredUser): array => $retiredUser->toArray(), $this->retiredMembers), 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/UserProfile/Model/User.php: -------------------------------------------------------------------------------- 1 | $this->id, 43 | 'name' => $this->name, 44 | 'email' => $this->email, 45 | 'role' => $this->role->value, 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Webhooks/Api/WebhooksApi.php: -------------------------------------------------------------------------------- 1 | httpClient->request( 29 | 'GET', 30 | 'webhooks/event', 31 | ); 32 | 33 | return array_map(static fn(string $event): Event => Event::from($event), $response['events']); 34 | } 35 | 36 | /** 37 | * @see https://developers.timeular.com/#f3ed186d-288f-4a7e-9a35-31c849f936c2 38 | * 39 | * @throws InvalidEventException 40 | * @throws InvalidUrlException 41 | * @throws MaximumSubscriptionsReachedException 42 | * @throws BadRequestException 43 | */ 44 | public function subscribe(string $event, string $targetUrl): Subscription 45 | { 46 | try { 47 | $response = $this->httpClient->request( 48 | 'POST', 49 | 'webhooks/subscription', 50 | [ 51 | 'event' => $event, 52 | 'target_url' => $targetUrl, 53 | ], 54 | ); 55 | } catch (BadRequestException $exception) { 56 | throw match ($exception->getMessage()) { 57 | 'invalid event provided' => InvalidEventException::fromEvent($event), 58 | 'invalid URL provided' => InvalidUrlException::fromUrl($targetUrl), 59 | 'maximum subscriptions per event exceeded' => MaximumSubscriptionsReachedException::fromEvent($event), 60 | default => $exception, 61 | }; 62 | } 63 | 64 | return Subscription::fromArray( 65 | [ 66 | 'id' => $response['id'], 67 | 'event' => $event, 68 | 'target_url' => $targetUrl, 69 | ], 70 | ); 71 | } 72 | 73 | /** 74 | * @see https://developers.timeular.com/#49f4cefd-7e39-437d-b411-469335b6cb15 75 | * 76 | * @throws SubscriptionNotFoundException 77 | */ 78 | public function unsubscribe(string $id): void 79 | { 80 | try { 81 | $this->httpClient->request( 82 | 'DELETE', 83 | sprintf('webhooks/subscription/%s', $id), 84 | ); 85 | } catch (NotFoundException) { 86 | throw SubscriptionNotFoundException::fromId($id); 87 | } 88 | } 89 | 90 | /** 91 | * @see https://developers.timeular.com/#295fadf6-7f50-48b2-8c1b-daa426046e68 92 | */ 93 | public function listSubscriptions(): array 94 | { 95 | $response = $this->httpClient->request( 96 | 'GET', 97 | 'webhooks/subscription', 98 | ); 99 | 100 | return array_map(static fn(array $subscription): Subscription => Subscription::fromArray($subscription), $response['subscriptions']); 101 | } 102 | 103 | /** 104 | * @seehttps://developers.timeular.com/#3e7db6eb-4bbe-400e-b155-ba7ffde690d4 105 | */ 106 | public function unsubscribeAllForUser(): void 107 | { 108 | $this->httpClient->request( 109 | 'DELETE', 110 | 'webhooks/subscription', 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Webhooks/Exception/InvalidEventException.php: -------------------------------------------------------------------------------- 1 | $event->value, Event::cases())))); 14 | } 15 | 16 | public static function fromEvent(string $event): self 17 | { 18 | return new self($event); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Webhooks/Exception/InvalidUrlException.php: -------------------------------------------------------------------------------- 1 | $this->id, 38 | 'event' => $this->event->value, 39 | 'target_url' => $this->targetUrl, 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/unit/Auth/Api/AuthApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 48 | $this->api = new AuthApi( 49 | (new HttpClientFactory($this->client))->create(), 50 | ); 51 | } 52 | 53 | #[Test] 54 | public function it_signs_in(): void 55 | { 56 | $response = (new Response(200)) 57 | ->withHeader('Content-Type', 'application/json') 58 | ->withBody(new Stream( 59 | <<client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $response); 67 | 68 | $token = $this->api->signIn('test', 'test'); 69 | 70 | self::assertIsString($token); 71 | } 72 | 73 | #[Test] 74 | public function it_fetches_api_key(): void 75 | { 76 | $authorizationResponse = (new Response(200)) 77 | ->withHeader('Content-Type', 'application/json') 78 | ->withBody(new Stream(json_encode(['token' => 'token']))) 79 | ; 80 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 81 | 82 | $response = (new Response(200)) 83 | ->withHeader('Content-Type', 'application/json') 84 | ->withBody(new Stream( 85 | <<getMessage(), 'Using multiple "Content-Type" headers is not supported.'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/unit/Http/Exception/NotFoundExceptionTest.php: -------------------------------------------------------------------------------- 1 | getMessage(), 'Not found.'); 22 | self::assertSame($exception->getCode(), 404); 23 | 24 | $exception = NotFoundException::withMessage('Custom message.'); 25 | 26 | self::assertInstanceOf(NotFoundException::class, $exception); 27 | self::assertSame($exception->getMessage(), 'Custom message.'); 28 | self::assertSame($exception->getCode(), 404); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/unit/Http/Exception/UnauthorizedExceptionTest.php: -------------------------------------------------------------------------------- 1 | getMessage(), 'Unauthorized.'); 22 | self::assertSame($exception->getCode(), 401); 23 | 24 | $exception = UnauthorizedException::withMessage('Custom message.'); 25 | 26 | self::assertInstanceOf(UnauthorizedException::class, $exception); 27 | self::assertSame($exception->getMessage(), 'Custom message.'); 28 | self::assertSame($exception->getCode(), 401); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/unit/Http/Exception/UnsupportedMediaTypeExceptionTest.php: -------------------------------------------------------------------------------- 1 | getMessage(), 'Media Type "application/json" is not supported.'); 22 | self::assertSame($exception->getCode(), 415); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/unit/Http/HttpClientTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 49 | $this->httpClient = (new HttpClientFactory($this->client))->create(); 50 | } 51 | 52 | #[Test] 53 | public function it_handles_authorization(): void 54 | { 55 | $authorizationResponse = (new Response(200)) 56 | ->withHeader('Content-Type', 'application/json') 57 | ->withBody(new Stream(json_encode(['token' => 'token']))) 58 | ; 59 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 60 | 61 | $response = (new Response(200)) 62 | ->withHeader('Content-Type', 'application/json') 63 | ->withBody(new Stream(json_encode([]))) 64 | ; 65 | $this->client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/test/endpoint', $response); 66 | 67 | $data = $this->httpClient->request('GET', 'test/endpoint'); 68 | 69 | self::assertIsArray($data); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/unit/Http/MediaTypeResolverTest.php: -------------------------------------------------------------------------------- 1 | mediaTypeResolver = new MediaTypeResolver(); 30 | } 31 | 32 | public static function correctCases(): \Generator 33 | { 34 | yield 'Request with "application/json" without parameter' => [(new Request())->withHeader('Content-Type', 'application/json'), 'application/json']; 35 | yield 'Request with "text/html" with single parameter' => [(new Request())->withHeader('Content-Type', 'text/html; charset=utf-8'), 'text/html']; 36 | yield 'Request with "multipart/form-data" with multiple parameters' => [(new Request())->withHeader('Content-Type', 'multipart/form-data; charset=utf-8; boundary=something'), 'multipart/form-data']; 37 | yield 'Response with "application/json" without parameter' => [(new Response())->withHeader('Content-Type', 'application/json'), 'application/json']; 38 | yield 'Response with "text/html" with single parameter' => [(new Response())->withHeader('Content-Type', 'text/html; charset=utf-8'), 'text/html']; 39 | yield 'Response with "multipart/form-data" with multiple parameters' => [(new Response())->withHeader('Content-Type', 'multipart/form-data; charset=utf-8; boundary=something'), 'multipart/form-data']; 40 | } 41 | 42 | #[Test] 43 | #[DataProvider('correctCases')] 44 | public function it_returns_correct_media_type(MessageInterface $message, string $mediaType): void 45 | { 46 | self::assertSame($this->mediaTypeResolver->getMediaTypeFromMessage($message), $mediaType); 47 | } 48 | 49 | #[Test] 50 | public function it_throws_exception_on_missing_header(): void 51 | { 52 | $response = new Response(); 53 | 54 | self::expectException(MissingContentTypeHeaderException::class); 55 | self::expectExceptionMessage('Missing "Content-Type" header.'); 56 | 57 | $this->mediaTypeResolver->getMediaTypeFromMessage($response); 58 | } 59 | 60 | #[Test] 61 | public function it_throws_exception_on_multiple_header_values(): void 62 | { 63 | $response = (new Response())->withAddedHeader('Content-Type', 'application/json')->withAddedHeader('Content-Type', 'text/html'); 64 | 65 | self::expectException(MultipleContentTypeValuesException::class); 66 | self::expectExceptionMessage('Using multiple "Content-Type" headers is not supported.'); 67 | 68 | $this->mediaTypeResolver->getMediaTypeFromMessage($response); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/unit/Http/RequestFactoryTest.php: -------------------------------------------------------------------------------- 1 | ['GET', $uri, []]; 35 | 36 | foreach (['POST', 'PUT', 'PATCH', 'DELETE'] as $method) { 37 | $uri = bin2hex(random_bytes(random_int(8, 32))); 38 | 39 | yield sprintf('"%s" to "%s" with empty payload', $method, $uri) => [$method, $uri, []]; 40 | yield sprintf('"%s" to "%s" with payload', $method, $uri) => [$method, $uri, ['test' => 123]]; 41 | } 42 | } 43 | 44 | #[Test] 45 | #[DataProvider('prepareRequest')] 46 | public function it_correctly_creates_request(string $method, string $uri, array $payload): void 47 | { 48 | $request = $this->requestFactory->create($method, $uri, $payload); 49 | 50 | self::assertEquals($method, $request->getMethod()); 51 | self::assertEquals(sprintf('%s/%s', RequestFactory::BASE_URI, $uri), (string) $request->getUri()); 52 | self::assertEquals('application/json', $request->getHeaderLine('Content-Type')); 53 | self::assertEquals($this->serializer->serialize($payload, 'application/json'), $request->getBody()->getContents()); 54 | } 55 | 56 | protected function setUp(): void 57 | { 58 | $this->requestFactory = (new RequestFactoryFactory())->create(); 59 | $this->serializer = (new SerializerFactory())->create(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/unit/Http/ResponseHandlerTest.php: -------------------------------------------------------------------------------- 1 | ['{}', 'array', 'application/json']; 62 | } 63 | 64 | public static function exceptionPerStatusCode(): \Generator 65 | { 66 | yield '"400" for "BadRequestException"' => [400, BadRequestException::class]; 67 | yield '"403" for "AccessDeniedException"' => [403, AccessDeniedException::class]; 68 | yield '"404" for "NotFoundException"' => [404, NotFoundException::class]; 69 | yield '"409" for "ConflictException"' => [409, ConflictException::class]; 70 | 71 | $statusCode = rand(501, 599); 72 | yield sprintf('"%s" for "HttpException"', $statusCode) => [$statusCode, HttpException::class]; 73 | } 74 | 75 | protected function setUp(): void 76 | { 77 | $this->responseHandler = (new ResponseHandlerFactory())->create(); 78 | } 79 | 80 | #[Test] 81 | #[DataProvider('returnTypePerMediaType')] 82 | public function it_returns_correct_data_for_media_type(string $data, string $format, string $mediaType): void 83 | { 84 | $body = new Stream($data); 85 | 86 | $response = (new Response()) 87 | ->withHeader('Content-Type', $mediaType) 88 | ->withBody($body) 89 | ; 90 | 91 | $handledData = $this->responseHandler->handle($response); 92 | 93 | self::assertEquals($format, gettype($handledData)); 94 | } 95 | 96 | #[Test] 97 | public function it_handles_logout(): void 98 | { 99 | $response = (new Response()) 100 | ->withHeader('Set-Cookie', '') 101 | ; 102 | 103 | $handledData = $this->responseHandler->handle($response); 104 | 105 | self::assertEquals('', $handledData); 106 | } 107 | 108 | #[Test] 109 | public function it_handles_incorrect_credentials(): void 110 | { 111 | $response = (new Response(401)) 112 | ->withHeader('Content-Type', 'application/json') 113 | ->withBody(new Stream('{"message": "foo"}')) 114 | ; 115 | 116 | self::expectExceptionObject(UnauthorizedException::withMessage('foo')); 117 | 118 | $this->responseHandler->handle($response); 119 | } 120 | 121 | #[Test] 122 | public function it_throws_unauthorized_exception(): void 123 | { 124 | $response = (new Response()) 125 | // Intentionally "break" this as that's how it's handled on server side :/ 126 | // ->withHeader('Content-Type', 'application/json') 127 | ->withStatus(401) 128 | ; 129 | 130 | self::expectExceptionObject(UnauthorizedException::withMessage()); 131 | 132 | $this->responseHandler->handle($response); 133 | } 134 | 135 | #[Test] 136 | public function it_throws_internal_server_exception(): void 137 | { 138 | $response = (new Response()) 139 | ->withStatus(500) 140 | ; 141 | 142 | self::expectExceptionObject(InternalServerErrorException::withMessage()); 143 | 144 | $this->responseHandler->handle($response); 145 | } 146 | 147 | #[Test] 148 | public function it_throws_bad_request_exception_on_missing_header(): void 149 | { 150 | $response = (new Response()); 151 | 152 | self::expectExceptionObject(BadRequestException::withMessage('Missing "Content-Type" header.')); 153 | 154 | $this->responseHandler->handle($response); 155 | } 156 | 157 | #[Test] 158 | public function it_throws_bad_request_exception_on_multiple_header_values(): void 159 | { 160 | $response = (new Response()) 161 | ->withHeader('Content-Type', 'application/json') 162 | ->withAddedHeader('Content-Type', 'text/html') 163 | ; 164 | 165 | self::expectExceptionObject(BadRequestException::withMessage('Using multiple "Content-Type" headers is not supported.')); 166 | 167 | $this->responseHandler->handle($response); 168 | } 169 | 170 | #[Test] 171 | public function it_throws_bad_request_exception_on_deserialization_error(): void 172 | { 173 | $response = (new Response()) 174 | ->withHeader('Content-Type', 'application/json') 175 | ->withBody(new Stream('')) 176 | ; 177 | 178 | self::expectException(BadRequestException::class); 179 | 180 | $this->responseHandler->handle($response); 181 | } 182 | 183 | #[Test] 184 | public function it_throws_exception_on_unsupported_media(): void 185 | { 186 | $response = (new Response()) 187 | ->withHeader('Content-Type', 'text/html') 188 | ; 189 | 190 | self::expectExceptionObject(UnsupportedMediaTypeException::fromMediaType('text/html')); 191 | 192 | $this->responseHandler->handle($response); 193 | } 194 | 195 | #[Test] 196 | #[DataProvider('exceptionPerStatusCode')] 197 | public function it_throws_correct_exception(int $statusCode, string $exceptionClass): void 198 | { 199 | $body = new Stream('{"message": "foo"}'); 200 | $response = (new Response()) 201 | ->withHeader('Content-Type', 'application/json') 202 | ->withBody($body) 203 | ->withStatus($statusCode) 204 | ; 205 | 206 | self::expectException($exceptionClass); 207 | 208 | $this->responseHandler->handle($response); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /tests/unit/Http/Serializer/SerializerTest.php: -------------------------------------------------------------------------------- 1 | serializer = (new SerializerFactory())->create(); 35 | } 36 | 37 | #[Test] 38 | #[DataProvider('dataProviderSuccessfulSerialize')] 39 | public function it_serializes(mixed $data, string $serialized, string $format): void 40 | { 41 | self::assertSame($this->serializer->serialize($data, $format), $serialized); 42 | } 43 | 44 | #[Test] 45 | #[DataProvider('dataProviderSuccessfulDeserialize')] 46 | public function it_deserializes(string $serialized, mixed $data, string $format): void 47 | { 48 | self::assertSame($this->serializer->deserialize($serialized, $format), $data); 49 | } 50 | 51 | #[Test] 52 | #[DataProvider('dataProviderUnsuccessfulSerialize')] 53 | public function it_throws_on_serializes(mixed $data, string $message, string $format): void 54 | { 55 | self::expectException(SerializeException::class); 56 | self::expectExceptionMessage(sprintf('Unable to serialize: %s', $message)); 57 | 58 | $this->serializer->serialize($data, $format); 59 | } 60 | 61 | #[Test] 62 | #[DataProvider('dataProviderUnsuccessfulDeserialize')] 63 | public function it_throws_on_deserializes(string $serialized, string $message, string $format): void 64 | { 65 | $this->expectException(DeserializeException::class); 66 | self::expectExceptionMessage(sprintf('Unable to deserialize: %s', $message)); 67 | 68 | $this->serializer->deserialize($serialized, $format); 69 | } 70 | 71 | #[Test] 72 | #[DataProvider('dataProviderUnsupportedFormat')] 73 | public function it_throws_on_unsupported_format_during_serialization(string $format): void 74 | { 75 | $this->expectException(MissingEncoderException::class); 76 | self::expectExceptionMessage(sprintf('Encoder for format "%s" does not exist', $format)); 77 | 78 | $this->serializer->serialize('test', $format); 79 | } 80 | 81 | #[Test] 82 | #[DataProvider('dataProviderUnsupportedFormat')] 83 | public function it_throws_on_unsupported_format_during_deserialization(string $format): void 84 | { 85 | $this->expectException(MissingEncoderException::class); 86 | self::expectExceptionMessage(sprintf('Encoder for format "%s" does not exist', $format)); 87 | 88 | $this->serializer->deserialize('test', $format); 89 | } 90 | 91 | public static function dataProviderSuccessfulSerialize(): \Generator 92 | { 93 | yield 'null' => [null, 'null', 'application/json']; 94 | yield 'empty array' => [[], '[]', 'application/json']; 95 | yield 'empty object' => [new \stdClass(), '{}', 'application/json']; 96 | yield 'object with properties' => [new class () { 97 | private string $notSerializable = 'asdf'; 98 | protected int $alsoNotSerializable = 123; 99 | public string $string = 'test'; 100 | public int $int = 456; 101 | public bool $bool = true; 102 | }, '{"string":"test","int":456,"bool":true}', 'application/json']; 103 | } 104 | 105 | public static function dataProviderSuccessfulDeserialize(): \Generator 106 | { 107 | yield 'null' => ['null', [], 'application/json']; 108 | yield 'empty array' => ['[]', [], 'application/json']; 109 | yield 'empty object' => ['{}', [], 'application/json']; 110 | yield 'object with properties' => ['{"string":"test","int":456,"bool":true}', [ 111 | 'string' => 'test', 112 | 'int' => 456, 113 | 'bool' => true, 114 | ], 'application/json']; 115 | yield 'csv as string' => ['test', 'test', 'text/csv']; 116 | yield 'xlsx as string' => ['test', 'test', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']; 117 | } 118 | 119 | public static function dataProviderUnsuccessfulSerialize(): \Generator 120 | { 121 | yield 'NaN' => [NAN, 'Inf and NaN cannot be JSON encoded', 'application/json']; 122 | yield 'resource' => [tmpfile(), 'Type is not supported', 'application/json']; 123 | } 124 | 125 | public static function dataProviderUnsuccessfulDeserialize(): \Generator 126 | { 127 | yield 'empty string' => ['', 'Syntax error', 'application/json']; 128 | yield 'single space' => [' ', 'Syntax error', 'application/json']; 129 | yield 'incorrect json' => ['{', 'Syntax error', 'application/json']; 130 | yield 'missing quotes' => ['{"string": test}', 'Syntax error', 'application/json']; 131 | } 132 | 133 | public static function dataProviderUnsupportedFormat(): \Generator 134 | { 135 | yield 'text/plain' => ['text/plain']; 136 | yield 'text/html' => ['text/html']; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/unit/HttpClientFactory.php: -------------------------------------------------------------------------------- 1 | httpClient, 23 | (new ResponseHandlerFactory())->create(), 24 | (new RequestFactoryFactory())->create(), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/unit/Integrations/Api/IntegrationsApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 50 | $this->api = new IntegrationsApi((new HttpClientFactory($this->client))->create()); 51 | } 52 | 53 | #[Test] 54 | public function it_returns_integrations_list(): void 55 | { 56 | $authorizationResponse = (new Response(200)) 57 | ->withHeader('Content-Type', 'application/json') 58 | ->withBody(new Stream(json_encode(['token' => 'token']))) 59 | ; 60 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 61 | 62 | $response = (new Response(200)) 63 | ->withHeader('Content-Type', 'application/json') 64 | ->withBody(new Stream( 65 | <<client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/integrations', $response); 77 | 78 | $data = $this->api->listEnabledIntegrations(); 79 | 80 | self::assertIsArray($data); 81 | 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/unit/RequestFactoryFactory.php: -------------------------------------------------------------------------------- 1 | create(), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Api/DevicesApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 52 | $this->api = new DevicesApi((new HttpClientFactory($this->client))->create()); 53 | } 54 | 55 | #[Test] 56 | public function it_returns_list_of_devices(): void 57 | { 58 | $authorizationResponse = (new Response(200)) 59 | ->withHeader('Content-Type', 'application/json') 60 | ->withBody(new Stream(json_encode(['token' => 'token']))) 61 | ; 62 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 63 | 64 | $response = (new Response(200)) 65 | ->withHeader('Content-Type', 'application/json') 66 | ->withBody(new Stream( 67 | <<client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/devices', $response); 82 | 83 | $devices = $this->api->list(); 84 | 85 | self::assertIsArray($devices); 86 | self::assertContainsOnlyInstancesOf(Device::class, $devices); 87 | } 88 | 89 | #[Test] 90 | public function it_activates_device(): void 91 | { 92 | $authorizationResponse = (new Response(200)) 93 | ->withHeader('Content-Type', 'application/json') 94 | ->withBody(new Stream(json_encode(['token' => 'token']))) 95 | ; 96 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 97 | 98 | $response = (new Response(200)) 99 | ->withHeader('Content-Type', 'application/json') 100 | ->withBody(new Stream( 101 | <<client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/devices/123/activate', $response); 112 | 113 | $device = $this->api->activate('123'); 114 | 115 | self::assertInstanceOf(Device::class, $device); 116 | } 117 | 118 | #[Test] 119 | public function it_deactivates_device(): void 120 | { 121 | $authorizationResponse = (new Response(200)) 122 | ->withHeader('Content-Type', 'application/json') 123 | ->withBody(new Stream(json_encode(['token' => 'token']))) 124 | ; 125 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 126 | 127 | $response = (new Response(200)) 128 | ->withHeader('Content-Type', 'application/json') 129 | ->withBody(new Stream( 130 | <<client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/devices/123/deactivate', $response); 141 | 142 | $device = $this->api->deactivate('123'); 143 | 144 | self::assertInstanceOf(Device::class, $device); 145 | } 146 | 147 | #[Test] 148 | public function it_edits_device(): void 149 | { 150 | $authorizationResponse = (new Response(200)) 151 | ->withHeader('Content-Type', 'application/json') 152 | ->withBody(new Stream(json_encode(['token' => 'token']))) 153 | ; 154 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 155 | 156 | $response = (new Response(200)) 157 | ->withHeader('Content-Type', 'application/json') 158 | ->withBody(new Stream( 159 | <<client->addResponse('PATCH', RequestFactoryInterface::BASE_URI . '/devices/123', $response); 170 | 171 | $device = $this->api->edit('123', 'Personal Tracker'); 172 | 173 | self::assertInstanceOf(Device::class, $device); 174 | } 175 | 176 | #[Test] 177 | public function it_disables_device(): void 178 | { 179 | $authorizationResponse = (new Response(200)) 180 | ->withHeader('Content-Type', 'application/json') 181 | ->withBody(new Stream(json_encode(['token' => 'token']))) 182 | ; 183 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 184 | 185 | $response = (new Response(200)) 186 | ->withHeader('Content-Type', 'application/json') 187 | ->withBody(new Stream( 188 | <<client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/devices/123/disable', $response); 199 | 200 | $device = $this->api->disable('123'); 201 | 202 | self::assertInstanceOf(Device::class, $device); 203 | } 204 | 205 | #[Test] 206 | public function it_enables_device(): void 207 | { 208 | $authorizationResponse = (new Response(200)) 209 | ->withHeader('Content-Type', 'application/json') 210 | ->withBody(new Stream(json_encode(['token' => 'token']))) 211 | ; 212 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 213 | 214 | $response = (new Response(200)) 215 | ->withHeader('Content-Type', 'application/json') 216 | ->withBody(new Stream( 217 | <<client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/devices/123/enable', $response); 228 | 229 | $device = $this->api->enable('123'); 230 | 231 | self::assertInstanceOf(Device::class, $device); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Api/ReportsApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 62 | $this->api = new ReportsApi((new HttpClientFactory($this->client))->create()); 63 | } 64 | 65 | #[Test] 66 | public function it_returns_all_data(): void 67 | { 68 | $authorizationResponse = (new Response(200)) 69 | ->withHeader('Content-Type', 'application/json') 70 | ->withBody(new Stream(json_encode(['token' => 'token']))) 71 | ; 72 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 73 | 74 | $response = (new Response(200)) 75 | ->withHeader('Content-Type', 'application/json') 76 | ->withBody(new Stream( 77 | << some text", 96 | "tags": [], 97 | "mentions": [ 98 | { 99 | "id": 1, 100 | "key": "123432234", 101 | "label": "some-mention", 102 | "scope": "timeular", 103 | "spaceId": "1" 104 | } 105 | ] 106 | } 107 | } 108 | ] 109 | } 110 | BODY, 111 | )) 112 | ; 113 | $this->client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/report/data/2016-01-01T00:00:00.000/2017-12-31T23:59:59.999', $response); 114 | 115 | $timeEntries = $this->api->getAllData(new \DateTimeImmutable('2016-01-01T00:00:00.000'), new \DateTimeImmutable('2017-12-31T23:59:59.999')); 116 | 117 | self::assertIsArray($timeEntries); 118 | self::assertContainsOnlyInstancesOf(ReportTimeEntry::class, $timeEntries); 119 | } 120 | 121 | #[Test] 122 | public function it_generates_report(): void 123 | { 124 | $authorizationResponse = (new Response(200)) 125 | ->withHeader('Content-Type', 'application/json') 126 | ->withBody(new Stream(json_encode(['token' => 'token']))) 127 | ; 128 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 129 | 130 | $response = (new Response(200)) 131 | ->withHeader('Content-Type', 'text/csv') 132 | ->withBody(new Stream( 133 | <<client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/report/2016-01-01T00:00:00.000/2017-12-31T23:59:59.999?timezone=Europe%2FWarsaw&fileType=csv', $response); 143 | 144 | $report = $this->api->generateReport( 145 | new \DateTimeImmutable('2016-01-01T00:00:00.000'), 146 | new \DateTimeImmutable('2017-12-31T23:59:59.999'), 147 | new \DateTimeZone('Europe/Warsaw'), 148 | ); 149 | 150 | self::assertIsString($report); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/ActiveTimeEntryTest.php: -------------------------------------------------------------------------------- 1 | format(Duration::FORMAT); 26 | 27 | $activeTimeEntry = ActiveTimeEntry::fromArray( 28 | [ 29 | 'id' => 34714420, 30 | 'activityId' => '1217348', 31 | 'startedAt' => $startedAt, 32 | 'note' => [], 33 | ], 34 | ); 35 | 36 | self::assertEquals(34714420, $activeTimeEntry->id); 37 | self::assertEquals('1217348', $activeTimeEntry->activityId); 38 | self::assertEquals(new \DateTimeImmutable($startedAt), $activeTimeEntry->startedAt); 39 | self::assertEquals(Note::fromArray([]), $activeTimeEntry->note); 40 | } 41 | 42 | #[Test] 43 | #[DataProvider('missingKeyData')] 44 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 45 | { 46 | self::expectException(MissingArrayKeyException::class); 47 | self::expectExceptionMessage(sprintf('Missing "%s" key for "ActiveTimeEntry" object.', $key)); 48 | 49 | ActiveTimeEntry::fromArray($data); 50 | } 51 | 52 | #[Test] 53 | public function it_converts_to_array(): void 54 | { 55 | $startedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 56 | 57 | $data = [ 58 | 'id' => 34714420, 59 | 'activityId' => '1217348', 60 | 'startedAt' => $startedAt, 61 | 'note' => Note::fromArray([])->toArray(), 62 | ]; 63 | 64 | $activeTimeEntry = ActiveTimeEntry::fromArray($data); 65 | 66 | self::assertSame($activeTimeEntry->toArray(), $data); 67 | } 68 | 69 | public static function missingKeyData(): \Generator 70 | { 71 | $fields = ['id', 'activityId', 'startedAt', 'note']; 72 | 73 | foreach ($fields as $field) { 74 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/ActivityTest.php: -------------------------------------------------------------------------------- 1 | '1', 26 | 'name' => 'sleeping', 27 | 'color' => '#a1b2c3', 28 | 'integration' => 'zei', 29 | 'spaceId' => '1', 30 | 'deviceSide' => null, 31 | ], 32 | ); 33 | 34 | self::assertEquals('1', $activity->id); 35 | self::assertEquals('sleeping', $activity->name); 36 | self::assertEquals('#a1b2c3', $activity->color); 37 | self::assertEquals('zei', $activity->integration); 38 | self::assertEquals('1', $activity->spaceId); 39 | self::assertNull($activity->deviceSide); 40 | } 41 | 42 | #[Test] 43 | public function it_creates_activity_from_array_with_empty_device_side(): void 44 | { 45 | $device = Activity::fromArray( 46 | [ 47 | 'id' => '1', 48 | 'name' => 'sleeping', 49 | 'color' => '#a1b2c3', 50 | 'integration' => 'zei', 51 | 'spaceId' => '1', 52 | 'deviceSide' => null, 53 | ], 54 | ); 55 | 56 | self::assertNull($device->deviceSide); 57 | 58 | $device = Activity::fromArray( 59 | [ 60 | 'id' => '1', 61 | 'name' => 'sleeping', 62 | 'color' => '#a1b2c3', 63 | 'integration' => 'zei', 64 | 'spaceId' => '1', 65 | ], 66 | ); 67 | 68 | self::assertNull($device->deviceSide); 69 | } 70 | 71 | #[Test] 72 | #[DataProvider('missingKeyData')] 73 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 74 | { 75 | self::expectException(MissingArrayKeyException::class); 76 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Activity" object.', $key)); 77 | 78 | Activity::fromArray($data); 79 | } 80 | 81 | #[Test] 82 | public function it_converts_to_array(): void 83 | { 84 | $data = [ 85 | 'id' => '1', 86 | 'name' => 'sleeping', 87 | 'color' => '#a1b2c3', 88 | 'integration' => 'zei', 89 | 'spaceId' => '1', 90 | 'deviceSide' => null, 91 | ]; 92 | 93 | $activity = Activity::fromArray($data); 94 | 95 | self::assertSame($activity->toArray(), $data); 96 | } 97 | 98 | public static function missingKeyData(): \Generator 99 | { 100 | $fields = ['id', 'name', 'color', 'integration', 'spaceId']; 101 | 102 | foreach ($fields as $field) { 103 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/DeviceTest.php: -------------------------------------------------------------------------------- 1 | 'QWER1234', 25 | 'name' => 'Personal Tracker', 26 | 'active' => $active = (bool) rand(0, 1), 27 | 'disabled' => $disabled = (bool) rand(0, 1), 28 | ], 29 | ); 30 | 31 | self::assertEquals('QWER1234', $device->serial); 32 | self::assertEquals('Personal Tracker', $device->name); 33 | self::assertEquals($active, $device->active); 34 | self::assertEquals($disabled, $device->disabled); 35 | } 36 | 37 | #[Test] 38 | public function it_creates_device_from_array_with_empty_name(): void 39 | { 40 | $device = Device::fromArray( 41 | [ 42 | 'serial' => 'QWER1234', 43 | 'name' => null, 44 | 'active' => (bool) rand(0, 1), 45 | 'disabled' => (bool) rand(0, 1), 46 | ], 47 | ); 48 | 49 | self::assertNull($device->name); 50 | 51 | $device = Device::fromArray( 52 | [ 53 | 'serial' => 'QWER1234', 54 | 'active' => (bool) rand(0, 1), 55 | 'disabled' => (bool) rand(0, 1), 56 | ], 57 | ); 58 | 59 | self::assertNull($device->name); 60 | } 61 | 62 | #[Test] 63 | #[DataProvider('missingKeyData')] 64 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 65 | { 66 | self::expectException(MissingArrayKeyException::class); 67 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Device" object.', $key)); 68 | 69 | Device::fromArray($data); 70 | } 71 | 72 | #[Test] 73 | public function it_converts_to_array(): void 74 | { 75 | $data = [ 76 | 'serial' => 'QWER1234', 77 | 'name' => 'Personal Tracker', 78 | 'active' => (bool) rand(0, 1), 79 | 'disabled' => (bool) rand(0, 1), 80 | ]; 81 | 82 | $device = Device::fromArray($data); 83 | 84 | self::assertSame($device->toArray(), $data); 85 | } 86 | 87 | public static function missingKeyData(): \Generator 88 | { 89 | $fields = ['serial', 'active', 'disabled']; 90 | 91 | foreach ($fields as $field) { 92 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/DurationTest.php: -------------------------------------------------------------------------------- 1 | format(Duration::FORMAT); 23 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 24 | 25 | $duration = Duration::fromArray( 26 | [ 27 | 'startedAt' => $startedAt, 28 | 'stoppedAt' => $stoppedAt, 29 | ], 30 | ); 31 | 32 | self::assertEquals(new \DateTimeImmutable($startedAt), $duration->startedAt); 33 | self::assertEquals(new \DateTimeImmutable($stoppedAt), $duration->stoppedAt); 34 | } 35 | 36 | #[Test] 37 | #[DataProvider('missingKeyData')] 38 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 39 | { 40 | self::expectException(MissingArrayKeyException::class); 41 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Duration" object.', $key)); 42 | 43 | Duration::fromArray($data); 44 | } 45 | 46 | #[Test] 47 | public function it_converts_to_array(): void 48 | { 49 | $startedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 50 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 51 | 52 | $data = [ 53 | 'startedAt' => $startedAt, 54 | 'stoppedAt' => $stoppedAt, 55 | ]; 56 | 57 | $duration = Duration::fromArray($data); 58 | 59 | self::assertSame($duration->toArray(), $data); 60 | } 61 | 62 | public static function missingKeyData(): \Generator 63 | { 64 | $fields = ['startedAt', 'stoppedAt']; 65 | 66 | foreach ($fields as $field) { 67 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/MentionTest.php: -------------------------------------------------------------------------------- 1 | 1, 25 | 'key' => '1234', 26 | 'label' => 'some mention', 27 | 'scope' => 'timeular', 28 | 'spaceId' => '1', 29 | ], 30 | ); 31 | 32 | self::assertEquals(1, $mention->id); 33 | self::assertEquals('1234', $mention->key); 34 | self::assertEquals('some mention', $mention->label); 35 | self::assertEquals('timeular', $mention->scope); 36 | self::assertEquals('1', $mention->spaceId); 37 | } 38 | 39 | #[Test] 40 | #[DataProvider('missingKeyData')] 41 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 42 | { 43 | self::expectException(MissingArrayKeyException::class); 44 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Mention" object.', $key)); 45 | 46 | Mention::fromArray($data); 47 | } 48 | 49 | #[Test] 50 | public function it_converts_to_array(): void 51 | { 52 | $data = [ 53 | 'id' => 1, 54 | 'key' => '1234', 55 | 'label' => 'some-mention', 56 | 'scope' => 'timeular', 57 | 'spaceId' => '1', 58 | ]; 59 | 60 | $mention = Mention::fromArray($data); 61 | 62 | self::assertSame($mention->toArray(), $data); 63 | } 64 | 65 | public static function missingKeyData(): \Generator 66 | { 67 | $fields = ['id', 'key', 'label', 'scope', 'spaceId']; 68 | 69 | foreach ($fields as $field) { 70 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/NoteTest.php: -------------------------------------------------------------------------------- 1 | 1, 25 | 'key' => '1234', 26 | 'label' => 'some-tag', 27 | 'scope' => 'timeular', 28 | 'spaceId' => '1', 29 | ]; 30 | $mention = [ 31 | 'id' => 1, 32 | 'key' => '1234', 33 | 'label' => 'some mention', 34 | 'scope' => 'timeular', 35 | 'spaceId' => '1', 36 | ]; 37 | 38 | $note = Note::fromArray( 39 | [ 40 | 'text' => null, 41 | 'tags' => [ 42 | $tag, 43 | ], 44 | 'mentions' => [ 45 | $mention, 46 | ], 47 | ], 48 | ); 49 | 50 | self::assertNull($note->text); 51 | self::assertEquals([Tag::fromArray($tag)], $note->tags); 52 | self::assertEquals([Mention::fromArray($mention)], $note->mentions); 53 | } 54 | 55 | #[Test] 56 | public function it_converts_to_array(): void 57 | { 58 | $data = [ 59 | 'text' => null, 60 | 'tags' => [ 61 | [ 62 | 'id' => 1, 63 | 'key' => '1234', 64 | 'label' => 'some-tag', 65 | 'scope' => 'timeular', 66 | 'spaceId' => '1', 67 | ], 68 | ], 69 | 'mentions' => [ 70 | [ 71 | 'id' => 1, 72 | 'key' => '1234', 73 | 'label' => 'some mention', 74 | 'scope' => 'timeular', 75 | 'spaceId' => '1', 76 | ], 77 | ], 78 | ]; 79 | 80 | $note = Note::fromArray($data); 81 | 82 | self::assertSame($note->toArray(), $data); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/ReportTimeEntryTest.php: -------------------------------------------------------------------------------- 1 | [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 31 | } 32 | } 33 | 34 | #[Test] 35 | public function it_creates_report_time_entry_from_array(): void 36 | { 37 | $startedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 38 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 39 | 40 | $activity = [ 41 | 'id' => '1', 42 | 'name' => 'sleeping', 43 | 'color' => '#a1b2c3', 44 | 'integration' => 'zei', 45 | 'spaceId' => '1', 46 | ]; 47 | 48 | $duration = Duration::fromArray( 49 | [ 50 | 'startedAt' => $startedAt, 51 | 'stoppedAt' => $stoppedAt, 52 | ], 53 | ); 54 | 55 | $reportTimeEntry = ReportTimeEntry::fromArray( 56 | [ 57 | 'id' => '34714420', 58 | 'activity' => $activity, 59 | 'duration' => [ 60 | 'startedAt' => $startedAt, 61 | 'stoppedAt' => $stoppedAt, 62 | ], 63 | 'note' => [], 64 | ], 65 | ); 66 | 67 | self::assertEquals('34714420', $reportTimeEntry->id); 68 | self::assertEquals(Activity::fromArray($activity), $reportTimeEntry->activity); 69 | self::assertEquals($duration, $reportTimeEntry->duration); 70 | self::assertEquals(Note::fromArray([]), $reportTimeEntry->note); 71 | } 72 | 73 | #[Test] 74 | #[DataProvider('missingKeyData')] 75 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 76 | { 77 | self::expectException(MissingArrayKeyException::class); 78 | self::expectExceptionMessage(sprintf('Missing "%s" key for "ReportTimeEntry" object.', $key)); 79 | 80 | ReportTimeEntry::fromArray($data); 81 | } 82 | 83 | #[Test] 84 | public function it_converts_to_array(): void 85 | { 86 | $startedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 87 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 88 | 89 | $activity = Activity::fromArray( 90 | [ 91 | 'id' => '1', 92 | 'name' => 'sleeping', 93 | 'color' => '#a1b2c3', 94 | 'integration' => 'zei', 95 | 'spaceId' => '1', 96 | ], 97 | ); 98 | 99 | $duration = Duration::fromArray( 100 | [ 101 | 'startedAt' => $startedAt, 102 | 'stoppedAt' => $stoppedAt, 103 | ], 104 | ); 105 | 106 | $data = [ 107 | 'id' => '34714420', 108 | 'activity' => $activity->toArray(), 109 | 'duration' => $duration->toArray(), 110 | 'note' => Note::fromArray([])->toArray(), 111 | ]; 112 | 113 | $timeEntry = ReportTimeEntry::fromArray($data); 114 | 115 | self::assertSame($timeEntry->toArray(), $data); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/TagTest.php: -------------------------------------------------------------------------------- 1 | 1, 25 | 'key' => '1234', 26 | 'label' => 'some-tag', 27 | 'scope' => 'timeular', 28 | 'spaceId' => '1', 29 | ], 30 | ); 31 | 32 | self::assertEquals(1, $tag->id); 33 | self::assertEquals('1234', $tag->key); 34 | self::assertEquals('some-tag', $tag->label); 35 | self::assertEquals('timeular', $tag->scope); 36 | self::assertEquals('1', $tag->spaceId); 37 | } 38 | 39 | #[Test] 40 | #[DataProvider('missingKeyData')] 41 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 42 | { 43 | self::expectException(MissingArrayKeyException::class); 44 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Tag" object.', $key)); 45 | 46 | Tag::fromArray($data); 47 | } 48 | 49 | #[Test] 50 | public function it_converts_to_array(): void 51 | { 52 | $data = [ 53 | 'id' => 1, 54 | 'key' => '1234', 55 | 'label' => 'some-tag', 56 | 'scope' => 'timeular', 57 | 'spaceId' => '1', 58 | ]; 59 | 60 | $tag = Tag::fromArray($data); 61 | 62 | self::assertSame($tag->toArray(), $data); 63 | } 64 | 65 | public static function missingKeyData(): \Generator 66 | { 67 | $fields = ['id', 'key', 'label', 'scope', 'spaceId']; 68 | 69 | foreach ($fields as $field) { 70 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/unit/TimeTracking/Model/TimeEntryTest.php: -------------------------------------------------------------------------------- 1 | format(Duration::FORMAT); 27 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 28 | 29 | $duration = Duration::fromArray( 30 | [ 31 | 'startedAt' => $startedAt, 32 | 'stoppedAt' => $stoppedAt, 33 | ], 34 | ); 35 | 36 | $timeEntry = TimeEntry::fromArray( 37 | [ 38 | 'id' => '34714420', 39 | 'activityId' => '1217348', 40 | 'duration' => [ 41 | 'startedAt' => $startedAt, 42 | 'stoppedAt' => $stoppedAt, 43 | ], 44 | 'note' => [], 45 | ], 46 | ); 47 | 48 | self::assertEquals('34714420', $timeEntry->id); 49 | self::assertEquals('1217348', $timeEntry->activityId); 50 | self::assertEquals($duration, $timeEntry->duration); 51 | self::assertEquals(Note::fromArray([]), $timeEntry->note); 52 | } 53 | 54 | #[Test] 55 | #[DataProvider('missingKeyData')] 56 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 57 | { 58 | self::expectException(MissingArrayKeyException::class); 59 | self::expectExceptionMessage(sprintf('Missing "%s" key for "TimeEntry" object.', $key)); 60 | 61 | TimeEntry::fromArray($data); 62 | } 63 | 64 | #[Test] 65 | public function it_converts_to_array(): void 66 | { 67 | $startedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 68 | $stoppedAt = (new \DateTimeImmutable())->format(Duration::FORMAT); 69 | 70 | $duration = Duration::fromArray( 71 | [ 72 | 'startedAt' => $startedAt, 73 | 'stoppedAt' => $stoppedAt, 74 | ], 75 | ); 76 | 77 | $data = [ 78 | 'id' => '34714420', 79 | 'activityId' => '1217348', 80 | 'duration' => $duration->toArray(), 81 | 'note' => Note::fromArray([])->toArray(), 82 | ]; 83 | 84 | $timeEntry = TimeEntry::fromArray($data); 85 | 86 | self::assertSame($timeEntry->toArray(), $data); 87 | } 88 | 89 | public static function missingKeyData(): \Generator 90 | { 91 | $fields = ['id', 'activityId', 'duration', 'note']; 92 | 93 | foreach ($fields as $field) { 94 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Api/SpaceApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 56 | $this->api = new SpaceApi((new HttpClientFactory($this->client))->create()); 57 | } 58 | 59 | #[Test] 60 | public function it_returns_spaces(): void 61 | { 62 | $authorizationResponse = (new Response(200)) 63 | ->withHeader('Content-Type', 'application/json') 64 | ->withBody(new Stream(json_encode(['token' => 'token']))) 65 | ; 66 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 67 | 68 | $response = (new Response(200)) 69 | ->withHeader('Content-Type', 'application/json') 70 | ->withBody(new Stream( 71 | <<client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/space', $response); 141 | 142 | $spaces = $this->api->spacesWithMembers(); 143 | 144 | self::assertIsArray($spaces); 145 | self::assertContainsOnlyInstancesOf(Space::class, $spaces); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Api/UserApiTest.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 52 | $this->api = new UserApi((new HttpClientFactory($this->client))->create()); 53 | } 54 | 55 | #[Test] 56 | public function it_returns_current_user_data(): void 57 | { 58 | $authorizationResponse = (new Response(200)) 59 | ->withHeader('Content-Type', 'application/json') 60 | ->withBody(new Stream(json_encode(['token' => 'token']))) 61 | ; 62 | $this->client->addResponse('POST', RequestFactoryInterface::BASE_URI . '/developer/sign-in', $authorizationResponse); 63 | 64 | $response = (new Response(200)) 65 | ->withHeader('Content-Type', 'application/json') 66 | ->withBody(new Stream( 67 | <<client->addResponse('GET', RequestFactoryInterface::BASE_URI . '/me', $response); 80 | 81 | $user = $this->api->me(); 82 | 83 | self::assertTrue(true); 84 | self::assertInstanceOf(Me::class, $user); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Model/MeTest.php: -------------------------------------------------------------------------------- 1 | '1', 25 | 'name' => 'my name', 26 | 'email' => 'my-name@example.com', 27 | 'defaultSpaceId' => '1', 28 | ], 29 | ); 30 | 31 | self::assertEquals('1', $user->userId); 32 | self::assertEquals('my name', $user->name); 33 | self::assertEquals('my-name@example.com', $user->email); 34 | self::assertEquals('1', $user->defaultSpaceId); 35 | } 36 | 37 | #[Test] 38 | #[DataProvider('missingKeyData')] 39 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 40 | { 41 | self::expectException(MissingArrayKeyException::class); 42 | self::expectExceptionMessage(sprintf('Missing "%s" key for "User" object.', $key)); 43 | 44 | Me::fromArray($data); 45 | } 46 | 47 | #[Test] 48 | public function it_converts_to_array(): void 49 | { 50 | $data = [ 51 | 'userId' => '1', 52 | 'name' => 'my name', 53 | 'email' => 'my-name@example.com', 54 | 'defaultSpaceId' => '1', 55 | ]; 56 | 57 | $user = Me::fromArray($data); 58 | 59 | self::assertSame($user->toArray(), $data); 60 | } 61 | 62 | public static function missingKeyData(): \Generator 63 | { 64 | $fields = ['userId', 'name', 'email', 'defaultSpaceId']; 65 | 66 | foreach ($fields as $field) { 67 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Model/RetiredUserTest.php: -------------------------------------------------------------------------------- 1 | '1', 25 | 'name' => 'my name', 26 | ], 27 | ); 28 | 29 | self::assertEquals('1', $retiredUser->id); 30 | self::assertEquals('my name', $retiredUser->name); 31 | } 32 | 33 | #[Test] 34 | #[DataProvider('missingKeyData')] 35 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 36 | { 37 | self::expectException(MissingArrayKeyException::class); 38 | self::expectExceptionMessage(sprintf('Missing "%s" key for "RetiredUser" object.', $key)); 39 | 40 | RetiredUser::fromArray($data); 41 | } 42 | 43 | #[Test] 44 | public function it_converts_to_array(): void 45 | { 46 | $data = [ 47 | 'id' => '1', 48 | 'name' => 'my name', 49 | ]; 50 | 51 | $user = RetiredUser::fromArray($data); 52 | 53 | self::assertSame($user->toArray(), $data); 54 | } 55 | 56 | public static function missingKeyData(): \Generator 57 | { 58 | $fields = ['id', 'name']; 59 | 60 | foreach ($fields as $field) { 61 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Model/SpaceTest.php: -------------------------------------------------------------------------------- 1 | '1', 29 | 'name' => 'my name', 30 | 'email' => 'my-name@example.com', 31 | 'role' => Role::Admin->value, 32 | ]; 33 | $retiredMember = [ 34 | 'id' => '1', 35 | 'name' => 'my name', 36 | ]; 37 | $space = Space::fromArray( 38 | [ 39 | 'id' => '1', 40 | 'name' => 'My Personal Space', 41 | 'default' => $default = (bool) rand(0, 1), 42 | 'members' => [ 43 | $member, 44 | ], 45 | 'retiredMembers' => [ 46 | $retiredMember, 47 | ], 48 | ], 49 | ); 50 | 51 | self::assertEquals('1', $space->id); 52 | self::assertEquals('My Personal Space', $space->name); 53 | self::assertEquals($default, $space->default); 54 | self::assertEquals([User::fromArray($member)], $space->members); 55 | self::assertEquals([RetiredUser::fromArray($retiredMember)], $space->retiredMembers); 56 | } 57 | 58 | #[Test] 59 | #[DataProvider('missingKeyData')] 60 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 61 | { 62 | self::expectException(MissingArrayKeyException::class); 63 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Space" object.', $key)); 64 | 65 | Space::fromArray($data); 66 | } 67 | 68 | #[Test] 69 | public function it_converts_to_array(): void 70 | { 71 | $member = [ 72 | 'id' => '1', 73 | 'name' => 'my name', 74 | 'email' => 'my-name@example.com', 75 | 'role' => Role::Admin->value, 76 | ]; 77 | $retiredMember = [ 78 | 'id' => '1', 79 | 'name' => 'my name', 80 | ]; 81 | $data = [ 82 | 'id' => '1', 83 | 'name' => 'My Personal Space', 84 | 'default' => (bool) rand(0, 1), 85 | 'members' => [ 86 | $member, 87 | ], 88 | 'retiredMembers' => [ 89 | $retiredMember, 90 | ], 91 | ]; 92 | 93 | $user = Space::fromArray($data); 94 | 95 | self::assertSame($user->toArray(), $data); 96 | } 97 | 98 | public static function missingKeyData(): \Generator 99 | { 100 | $fields = ['id', 'name', 'default', 'members', 'retiredMembers']; 101 | 102 | foreach ($fields as $field) { 103 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/unit/UserProfile/Model/UserTest.php: -------------------------------------------------------------------------------- 1 | '1', 26 | 'name' => 'my name', 27 | 'email' => 'my-name@example.com', 28 | 'role' => Role::Admin->value, 29 | ], 30 | ); 31 | 32 | self::assertEquals('1', $user->id); 33 | self::assertEquals('my name', $user->name); 34 | self::assertEquals('my-name@example.com', $user->email); 35 | self::assertEquals(Role::Admin, $user->role); 36 | } 37 | 38 | #[Test] 39 | #[DataProvider('missingKeyData')] 40 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 41 | { 42 | self::expectException(MissingArrayKeyException::class); 43 | self::expectExceptionMessage(sprintf('Missing "%s" key for "User" object.', $key)); 44 | 45 | User::fromArray($data); 46 | } 47 | 48 | #[Test] 49 | public function it_converts_to_array(): void 50 | { 51 | $data = [ 52 | 'id' => '1', 53 | 'name' => 'my name', 54 | 'email' => 'my-name@example.com', 55 | 'role' => Role::Admin->value, 56 | ]; 57 | 58 | $user = User::fromArray($data); 59 | 60 | self::assertSame($user->toArray(), $data); 61 | } 62 | 63 | 64 | public static function missingKeyData(): \Generator 65 | { 66 | $fields = ['id', 'name', 'email', 'role']; 67 | 68 | foreach ($fields as $field) { 69 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/unit/Webhooks/Model/SubscriptionTest.php: -------------------------------------------------------------------------------- 1 | '123456', 25 | 'event' => 'trackingStarted', 26 | 'target_url' => 'https://example.org/some-endpoint', 27 | ], 28 | ); 29 | 30 | self::assertEquals('123456', $subscription->id); 31 | self::assertEquals('trackingStarted', $subscription->event->value); 32 | self::assertEquals('https://example.org/some-endpoint', $subscription->targetUrl); 33 | } 34 | 35 | #[Test] 36 | #[DataProvider('missingKeyData')] 37 | public function it_throws_exception_on_missing_array_key(array $data, string $key): void 38 | { 39 | self::expectException(MissingArrayKeyException::class); 40 | self::expectExceptionMessage(sprintf('Missing "%s" key for "Subscription" object.', $key)); 41 | 42 | Subscription::fromArray($data); 43 | } 44 | 45 | #[Test] 46 | public function it_converts_to_array(): void 47 | { 48 | $data = [ 49 | 'id' => '123456', 50 | 'event' => 'trackingStarted', 51 | 'target_url' => 'https://example.org/some-endpoint', 52 | ]; 53 | 54 | $subscription = Subscription::fromArray($data); 55 | 56 | self::assertEquals($data, $subscription->toArray()); 57 | } 58 | 59 | public static function missingKeyData(): \Generator 60 | { 61 | $fields = ['id', 'event', 'target_url']; 62 | 63 | foreach ($fields as $field) { 64 | yield sprintf('Missing "%s" key', $field) => [array_fill_keys(array_diff($fields, [$field]), 'test'), $field]; 65 | } 66 | } 67 | } 68 | --------------------------------------------------------------------------------