diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..0107af1
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,25 @@
+{
+ "permissions": {
+ "allow": [
+ "Edit(file_path:tests/Pest.php)",
+ "Edit(file_path:tests/TestCase.php)",
+ "Edit(file_path:generator/**)",
+ "Write(file_path:tests/Pest.php)",
+ "Write(file_path:tests/TestCase.php)",
+ "Write(file_path:generator/**)"
+ ],
+ "deny": [
+ "Edit(file_path:src/TimaticConnector.php)",
+ "Edit(file_path:src/Dto/**)",
+ "Edit(file_path:src/Requests/**)",
+ "Edit(file_path:src/Resource/**)",
+ "Edit(file_path:tests/*Test.php)",
+ "Write(file_path:src/TimaticConnector.php)",
+ "Write(file_path:src/Dto/**)",
+ "Write(file_path:src/Requests/**)",
+ "Write(file_path:src/Resource/**)",
+ "Write(file_path:tests/*Test.php)"
+ ],
+ "ask": []
+ }
+}
diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
new file mode 100644
index 0000000..a0f9324
--- /dev/null
+++ b/.github/workflows/code-style.yml
@@ -0,0 +1,31 @@
+name: Code Style
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ workflow_dispatch:
+
+jobs:
+ pint:
+ runs-on: ubuntu-latest
+
+ name: Laravel Pint
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ extensions: dom, curl, libxml, mbstring, zip
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction
+
+ - name: Run Laravel Pint
+ run: ./vendor/bin/pint --test
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..81fb6b9
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,31 @@
+name: Static Analysis
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ workflow_dispatch:
+
+jobs:
+ larastan:
+ runs-on: ubuntu-latest
+
+ name: Larastan
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ extensions: dom, curl, libxml, mbstring, zip
+ coverage: none
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction
+
+ - name: Run Larastan
+ run: ./vendor/bin/phpstan analyse --memory-limit=2G
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..c86395e
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,45 @@
+name: Tests
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ workflow_dispatch:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [8.2, 8.3, 8.4]
+ laravel: [10.*, 11.*, 12.*]
+
+ name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
+ coverage: none
+
+ - name: Setup problem matchers
+ run: |
+ echo "::add-matcher::${{ runner.tool_cache }}/php.json"
+ echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+ - name: Install dependencies
+ run: |
+ composer remove "crescat-io/saloon-sdk-generator" "larastan/larastan" "laravel/boost" "laravel/pint" --dev --no-interaction
+ composer require "laravel/framework:${{ matrix.laravel }}" " saloonphp/laravel-plugin" --no-interaction --no-update
+ composer update --prefer-dist --no-interaction
+
+ - name: Run Pest tests
+ run: ./vendor/bin/pest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..723f82e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/vendor/
+/.idea/
+composer.lock
+.phpunit.result.cache
+.DS_Store
+openapi.json
+.claude/settings.local.json
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..addbfc6
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,360 @@
+Make sure the package is compatible with Laravel 10, 11 and 12
+
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.4.14
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/pint (PINT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/breeze (BREEZE) - v2
+- laravel/mcp (MCP) - v0
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- vue (VUE) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v3
+
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+
+=== laravel/v11 rules ===
+
+## Laravel 11
+
+- Use the `search-docs` tool to get version specific documentation.
+- Laravel 11 brought a new streamlined file structure which this project now uses.
+
+### Laravel 11 Structure
+- No middleware files in `app/Http/Middleware/`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+### New Artisan Commands
+- List Artisan commands using Boost's MCP tool, if available. New commands available in Laravel 11:
+ - `php artisan make:enum`
+ - `php artisan make:class`
+ - `php artisan make:interface`
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
+
+
+=== phpunit/core rules ===
+
+## PHPUnit Core
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit ` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test`.
+- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
+
+
+=== tailwindcss/core rules ===
+
+## Tailwind Core
+
+- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
+- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing, don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+
+=== tailwindcss/v3 rules ===
+
+## Tailwind 3
+
+- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
+
+
+=== timatic-sdk rules ===
+
+## Timatic PHP SDK - Auto-Generated Files
+
+This SDK is automatically generated from the OpenAPI specification. **DO NOT manually edit auto-generated files**.
+
+### HTTP Methods Policy
+
+This SDK **does not support PUT requests**. Only the following HTTP methods are supported:
+- **POST** - Create new resources
+- **PATCH** - Partially update existing resources
+- **GET** - Retrieve resources
+- **DELETE** - Remove resources
+
+PUT is intentionally excluded as PATCH provides better semantics for partial updates in JSON:API applications. The generator will throw an exception if it encounters PUT endpoints.
+
+### ❌ NEVER EDIT THESE FILES/DIRECTORIES:
+- **`src/Dto/`** - All Model/DTO classes (auto-generated with flattened JSON:API attributes)
+- **`src/Requests/`** - All Request classes (auto-generated from OpenAPI endpoints)
+- **`src/Resource/`** - All Resource classes (auto-generated to group related requests)
+- **`src/TimaticConnector.php`** - The main Connector class (auto-generated with custom JSON:API configuration)
+- **`tests/*Test.php`** - All test files (auto-generated from OpenAPI endpoints, Pest.php and TestCase.php are preserved)
+
+### ✅ SAFE TO EDIT:
+- **`src/Concerns/`** - Base classes and traits (Model, HasAttributes, HasFilters, etc.)
+- **`src/Attributes/`** - PHP attributes (Property, DateTime)
+- **`src/Responses/`** - Custom response classes (TimaticResponse)
+- **`src/Pagination/`** - Pagination classes (JsonApiPaginator)
+- **`src/Providers/`** - Laravel service providers
+- **`src/Facades/`** - Laravel facades
+- **`config/timatic.php`** - Configuration file
+- **`tests/Pest.php`** - Pest configuration (preserved during regeneration)
+- **`tests/TestCase.php`** - Test base class with Orchestra Testbench setup (preserved during regeneration)
+- **`generator/`** - Generator classes that customize SDK generation
+- **`generator/Stubs/`** - Custom Pest test stub templates for JSON:API
+
+## SDK Regeneration Commands
+
+**IMPORTANT:** Always use `composer regenerate` to regenerate the SDK. Never run `php generator/generate.php` directly.
+
+To update auto-generated files from the latest OpenAPI spec:
+
+```bash
+# Run the regenerate script (handles all steps)
+composer regenerate
+
+# Verify with tests
+composer test
+```
+
+**What `composer regenerate` does:**
+1. Downloads OpenAPI spec from the API
+2. Generates all source files (Connector, DTOs, Requests, Resources)
+3. Generates Pest test files for all endpoints
+4. Updates autoloader with `composer dump-autoload`
+5. Formats all code with Laravel Pint
+
+## Modifying Auto-Generated Code
+
+If you need to change how files are generated, update the appropriate generator:
+
+### Generator Mapping per Directory:
+
+| Directory | Generator | Description |
+|-----------|-----------|-------------|
+| `src/TimaticConnector.php` | `generator/JsonApiConnectorGenerator.php` | Generates the main Connector with JSON:API configuration, pagination support, and custom headers. |
+| `src/Dto/` | `generator/JsonApiDtoGenerator.php` | Generates Models from OpenAPI schemas. Flattens JSON:API attributes into typed properties. Adds `#[Property]` and `#[DateTime]` attributes. |
+| `src/Requests/` | `generator/JsonApiRequestGenerator.php` | Generates Request classes. Adds Model support for POST/PUT/PATCH. Adds `Paginatable` interface to GET collections. |
+| `src/Resource/` | `generator/generate.php` (post-processing) | Resources are generated by SDK generator, then post-processed in generate.php to add `$data` parameters to POST/PUT/PATCH methods. |
+
+### Generator Details:
+
+- **`generator/JsonApiConnectorGenerator.php`** - Connector class generation
+ - Extends the base ConnectorGenerator from Saloon SDK Generator
+ - Adds `HasPagination` interface implementation
+ - Overrides `resolveBaseUrl()` to use Laravel config
+ - Adds `defaultHeaders()` with JSON:API headers and Bearer token support
+ - Adds `resolveResponseClass()` to return custom TimaticResponse
+ - Adds `paginate()` method with JsonApiPaginator support
+
+- **`generator/JsonApiDtoGenerator.php`** - Model/DTO generation
+ - Extends `Model` instead of Spatie Data
+ - Flattens JSON:API `attributes` object into direct properties
+ - Adds `#[Property]` attribute to all properties
+ - Converts `format: date-time` fields to Carbon instances with `#[DateTime]` attribute
+
+- **`generator/JsonApiRequestGenerator.php`** - Request class generation
+ - Adds `Model|array $data` parameter to POST/PUT/PATCH requests (when no body parameters exist)
+ - Adds `Paginatable` interface to GET collection requests (requests without path parameters)
+ - Generates `defaultBody()` method for JSON:API serialization
+
+- **`generator/generate.php`** - Main generation script + Resource post-processing
+ - Downloads OpenAPI spec from API
+ - Runs code generation with custom generators
+ - **Post-processes Resource classes**: Adds `$data` parameter to POST/PUT/PATCH methods
+ - Writes all generated files
+
+After modifying generators, regenerate and test:
+```bash
+composer regenerate
+composer test
+```
diff --git a/README.md b/README.md
index c4e3755..7334abb 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,258 @@
-# timatic-php-sdk
+# Timatic PHP SDK
+
+[](https://github.com/Timatic/timatic-php-sdk/actions/workflows/tests.yml)
+[](https://github.com/Timatic/timatic-php-sdk/actions/workflows/code-style.yml)
+[](https://github.com/Timatic/timatic-php-sdk/actions/workflows/static-analysis.yml)
+
+A Laravel package for the Timatic API, built with [Saloon](https://docs.saloon.dev/) and automatically generated from OpenAPI specifications.
+
+## Requirements
+
+- PHP 8.2 or higher
+- Laravel 10.x or higher
+
+## Installation
+
+```bash
+composer require timatic/php-sdk
+```
+
+## Configuration
+
+The package automatically registers itself via Laravel auto-discovery.
+
+Publish the config file:
+
+```bash
+php artisan vendor:publish --tag=timatic-config
+```
+
+Add your API credentials to `.env`:
+
+```env
+TIMATIC_BASE_URL=https://api.app.timatic.test
+TIMATIC_API_TOKEN=your-api-token-here
+```
+
+## Usage
+
+### Using Dependency Injection
+
+The SDK connector is automatically registered in Laravel's service container, making it easy to inject into your controllers, commands, and other classes:
+
+```php
+use Timatic\SDK\TimaticConnector;
+use Timatic\SDK\Requests\BudgetType\GetBudgetTypeCollection;
+
+class BudgetController extends Controller
+{
+ public function __construct(
+ protected TimaticConnector $timatic
+ ) {}
+
+ public function index()
+ {
+ // Using resource methods
+ $budgets = $this->timatic->budget()->getBudgets()->dto();
+
+ // Using direct send() with dtoOrFail() for automatic DTO conversion
+ $budgetTypes = $this->timatic
+ ->send(new GetBudgetTypeCollection())
+ ->dtoOrFail();
+
+ return view('budgets.index', compact('budgets', 'budgetTypes'));
+ }
+
+ public function store(Request $request)
+ {
+ $budget = new \Timatic\SDK\Dto\Budget([
+ 'title' => $request->input('title'),
+ 'totalPrice' => $request->input('total_price'),
+ ]);
+
+ $created = $this->timatic
+ ->send(new \Timatic\SDK\Requests\Budget\PostBudgets($budget))
+ ->dtoOrFail();
+
+ return redirect()->route('budgets.show', $created->id);
+ }
+}
+```
+
+**In Console Commands:**
+
+```php
+use Timatic\SDK\TimaticConnector;
+
+class SyncBudgetsCommand extends Command
+{
+ public function handle(TimaticConnector $timatic): int
+ {
+ $budgets = $timatic->budget()->getBudgets()->dto();
+
+ foreach ($budgets as $budget) {
+ // Process budgets
+ }
+
+ return Command::SUCCESS;
+ }
+}
+```
+
+### Pagination
+
+The SDK supports automatic pagination for all collection endpoints using Saloon's pagination plugin:
+
+```php
+use Timatic\SDK\TimaticConnector;
+use Timatic\SDK\Requests\Budget\GetBudgets;
+
+class BudgetController extends Controller
+{
+ public function index(TimaticConnector $timatic)
+ {
+ // Create a paginator
+ $paginator = $timatic->paginate(new GetBudgets());
+
+ // Optionally set items per page (default is API's default)
+ $paginator->setPerPageLimit(50);
+
+ // Iterate through all pages automatically
+ foreach ($paginator->items() as $budget) {
+ // Process each budget across all pages
+ // The paginator handles pagination automatically
+ }
+
+ // Or collect all items at once
+ $allBudgets = $paginator->collect();
+ }
+}
+```
+
+The paginator:
+- Automatically handles JSON:API pagination (`page[number]` and `page[size]`)
+- Detects the last page via `links.next`
+- Works with all GET collection requests (GetBudgets, GetCustomers, GetUsers, etc.)
+
+### Custom Response Methods
+
+All responses are instances of `TimaticResponse` which extends Saloon's Response with JSON:API convenience methods:
+
+```php
+$response = $timatic->budget()->getBudgets();
+
+// Get the first item from a collection
+$firstBudget = $response->firstItem();
+
+// Check for errors
+if ($response->hasErrors()) {
+ $errors = $response->errors();
+ // Handle errors...
+}
+
+// Access JSON:API meta information
+$meta = $response->meta();
+$total = $meta['total'] ?? 0;
+
+// Access pagination links
+$links = $response->links();
+$nextPage = $links['next'] ?? null;
+
+// Access included resources
+$included = $response->included();
+foreach ($included as $resource) {
+ // Process related resources
+}
+```
+
+## HTTP Methods
+
+This SDK follows REST best practices and **does not support PUT requests**. Instead:
+
+- **POST** - Create new resources
+- **PATCH** - Partially update existing resources
+- **GET** - Retrieve resources
+- **DELETE** - Remove resources
+
+PUT is intentionally excluded because resources are never completely replaced by Timatic.
+
+## Available Resources
+
+The SDK provides access to the following resources:
+
+- **Budgets** - Manage budgets and budget entries
+- **Customers** - Customer management
+- **Users** - User management
+- **Teams** - Team management
+- **Entries** - Time entry management
+- **Incidents** - Incident tracking
+- **Changes** - Change tracking
+- **Overtimes** - Overtime management
+- **Events** - Event logging
+- And more...
+
+## JSON:API Support
+
+This SDK uses a custom **JSON:API DTO Generator** that automatically flattens JSON:API attributes into proper Model properties. Instead of having generic `$attributes`, `$type`, and `$relationships` objects, each model has specific typed properties.
+
+### Example
+
+Instead of:
+```php
+$budget->attributes->title; // ❌ Generic structure
+```
+
+You get:
+```php
+$budget->title; // ✅ Proper typed property
+$budget->budgetTypeId;
+$budget->startedAt; // Carbon instance for datetime fields
+```
+
+### Model Features
+
+- **Extends `Model` base class** with JSON:API support
+- **Property attributes** via `#[Property]` for serialization
+- **DateTime handling** with Carbon instances
+- **Type safety** with PHP 8.1+ type hints
+- **HasAttributes trait** for easy attribute manipulation
+
+## Regenerating the SDK
+
+This SDK is automatically generated from the Timatic API OpenAPI specification using a custom JSON:API generator. To regenerate the SDK with the latest API changes:
+
+```bash
+composer regenerate
+```
+
+This will:
+1. Download the latest OpenAPI specification from the API
+2. Generate Models with flattened JSON:API attributes
+3. Update the autoloader
+4. Format the code with Laravel Pint
+
+### How It Works
+
+The SDK uses a custom `JsonApiDtoGenerator` that:
+1. Detects JSON:API schemas in the OpenAPI specification
+2. Extracts properties from the `attributes` object
+3. Generates proper Model classes with specific properties
+4. Adds `#[Property]` and `#[DateTime]` attributes
+5. Uses Carbon for datetime fields
+
+## Development
+
+### Running Tests
+
+```bash
+composer test
+```
+
+## License
+
+This package is licensed under the Elastic License 2.0 (ELv2).
+
+## Credits
+
+- Built with [Saloon](https://docs.saloon.dev/)
+- Generated using [Saloon SDK Generator](https://docs.saloon.dev/installable-plugins/sdk-generator)
diff --git a/claude-todo.md b/claude-todo.md
new file mode 100644
index 0000000..635dc93
--- /dev/null
+++ b/claude-todo.md
@@ -0,0 +1,7 @@
+Focus steeds op een enkele hoofdtaak. Als deze klaar is geef dan de gelegenheid om feedback te geven en de resultaten te committen.
+
+## TASK: replace "type: resource" with actual resource in mocked GET data
+
+## Gebruik raw JSON alleen om operationId → schema reference mapping te vinden (totdat we vendor package kunnen patchen)
+
+**Note:** Each task is independent and can be completed, tested, and committed separately.
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..355847f
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,65 @@
+{
+ "name": "timatic/php-sdk",
+ "description": "PHP SDK for Timatic API built with Saloon",
+ "type": "library",
+ "license": "Elastic-2.0",
+ "autoload": {
+ "psr-4": {
+ "Timatic\\SDK\\": "src/",
+ "Timatic\\SDK\\Generator\\": "generator/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Timatic\\SDK\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "php": "^8.2",
+ "saloonphp/saloon": "^3.0",
+ "nesbot/carbon": "^2.0|^3.0",
+ "saloonphp/pagination-plugin": "^2.0"
+ },
+ "require-dev": {
+ "laravel/boost": "^1.8",
+ "phpunit/phpunit": "^10.0|^11.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "laravel/pint": "^1.0",
+ "larastan/larastan": "^3.0",
+ "orchestra/testbench": "^8.0|^9.0|^10.0",
+ "crescat-io/saloon-sdk-generator": "dev-add-hooks-to-pest-generator"
+ },
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/tomasvanrijsse/saloon-sdk-generator"
+ }
+ ],
+ "scripts": {
+ "test": "pest",
+ "format": "pint",
+ "analyse": "phpstan analyse --memory-limit=2G",
+ "regenerate": [
+ "php generator/generate.php",
+ "@composer dump-autoload",
+ "@format"
+ ]
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Timatic\\SDK\\Providers\\TimaticServiceProvider"
+ ],
+ "aliases": {
+ "Timatic": "Timatic\\SDK\\Facades\\Timatic"
+ }
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ }
+}
diff --git a/config/timatic.php b/config/timatic.php
new file mode 100644
index 0000000..16dfc02
--- /dev/null
+++ b/config/timatic.php
@@ -0,0 +1,44 @@
+ env('TIMATIC_BASE_URL', 'https://api.app.timatic.test'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Timatic API Token
+ |--------------------------------------------------------------------------
+ |
+ | Your Timatic API authentication token. This will be sent as a Bearer
+ | token in the Authorization header.
+ |
+ */
+
+ 'api_token' => env('TIMATIC_API_TOKEN'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Default Headers
+ |--------------------------------------------------------------------------
+ |
+ | The SDK automatically includes the following headers:
+ | - Accept: application/vnd.api+json
+ | - Content-Type: application/vnd.api+json
+ | - Authorization: Bearer {api_token} (if api_token is set)
+ |
+ | This configuration is reserved for future use if you need to add
+ | additional custom headers beyond the defaults.
+ |
+ */
+
+];
diff --git a/example.php b/example.php
new file mode 100644
index 0000000..14add1e
--- /dev/null
+++ b/example.php
@@ -0,0 +1,59 @@
+user()->getUsers();
+if ($response->successful()) {
+ $users = $response->json();
+ echo 'Found '.count($users['data'] ?? [])." users\n\n";
+}
+
+// Example 2: Get all customers
+echo "Fetching customers...\n";
+$response = $timatic->customer()->getCustomers();
+if ($response->successful()) {
+ $customers = $response->json();
+ echo 'Found '.count($customers['data'] ?? [])." customers\n\n";
+}
+
+// Example 3: Get budgets
+echo "Fetching budgets...\n";
+$response = $timatic->budget()->getBudgets();
+if ($response->successful()) {
+ $budgets = $response->json();
+ echo 'Found '.count($budgets['data'] ?? [])." budgets\n\n";
+}
+
+// Example 4: Get current user info
+echo "Fetching current user info...\n";
+$response = $timatic->me()->getMes();
+if ($response->successful()) {
+ $me = $response->json();
+ echo 'Current user: '.($me['data']['name'] ?? 'Unknown')."\n\n";
+}
+
+// Example 5: Create a new entry (commented out to prevent accidental execution)
+
+echo "Creating a new entry...\n";
+$response = $timatic->entry()->postEntries(new \Timatic\SDK\Dto\Entry([
+ 'user_id' => 1,
+ 'customer_id' => 1,
+ 'date' => date('Y-m-d'),
+ 'hours' => 2.5,
+ 'description' => 'Working on SDK integration',
+]));
+
+if ($response->successful()) {
+ echo "Entry created successfully!\n";
+ $entry = $response->json();
+ var_dump($entry);
+}
+
+echo "Examples completed!\n";
diff --git a/generator/JsonApiConnectorGenerator.php b/generator/JsonApiConnectorGenerator.php
new file mode 100644
index 0000000..c2941d3
--- /dev/null
+++ b/generator/JsonApiConnectorGenerator.php
@@ -0,0 +1,117 @@
+getNamespaces())[0];
+ /** @var ClassType $classType */
+ $classType = array_values($namespace->getClasses())[0];
+
+ // Add HasPagination interface
+ $namespace->addUse(HasPagination::class);
+ $classType->addImplement(HasPagination::class);
+
+ // Add additional imports for custom methods
+ $namespace->addUse(Request::class);
+ $namespace->addUse(JsonApiPaginator::class);
+ $namespace->addUse(TimaticResponse::class);
+
+ // Keep the empty constructor for test compatibility
+ // (PestTestGenerator needs it)
+
+ // Override resolveBaseUrl to use Laravel config
+ $resolveBaseUrl = $classType->getMethod('resolveBaseUrl');
+ $resolveBaseUrl->setBody('return config(\'timatic.base_url\');');
+
+ // Store all resource methods (to re-add them later in correct order)
+ $resourceMethods = [];
+ foreach ($classType->getMethods() as $methodName => $method) {
+ $resourceMethods[$methodName] = $method;
+ $classType->removeMethod($methodName);
+ }
+
+ // Add defaultHeaders method (after resolveBaseUrl)
+ $this->addDefaultHeaders($classType);
+
+ // Add resolveResponseClass method
+ $this->addResolveResponseClassMethod($classType);
+
+ // Add paginate method
+ $this->addPaginateMethod($classType);
+
+ // Re-add resource methods after custom configuration methods
+ foreach ($resourceMethods as $methodName => $method) {
+ $classType->setMethods(array_merge($classType->getMethods(), [$methodName => $method]));
+ }
+
+ return $phpFile;
+ }
+
+ public function removeEmptyConstructorIfPresent(ClassType $classType): void
+ {
+ if ($classType->hasMethod('__construct')) {
+ $constructor = $classType->getMethod('__construct');
+ // Only remove if it's empty (no parameters and no body)
+ if (count($constructor->getParameters()) === 0 && empty(trim($constructor->getBody()))) {
+ $classType->removeMethod('__construct');
+ }
+ }
+ }
+
+ public function addDefaultHeaders(ClassType $classType): void
+ {
+ $defaultHeaders = $classType->addMethod('defaultHeaders')
+ ->setProtected()
+ ->setReturnType('array');
+
+ $defaultHeaders->addBody('$headers = [');
+ $defaultHeaders->addBody(' \'Accept\' => \'application/vnd.api+json\',');
+ $defaultHeaders->addBody(' \'Content-Type\' => \'application/vnd.api+json\',');
+ $defaultHeaders->addBody('];');
+ $defaultHeaders->addBody('');
+ $defaultHeaders->addBody('if ($token = config(\'timatic.api_token\')) {');
+ $defaultHeaders->addBody(' $headers[\'Authorization\'] = "Bearer {$token}";');
+ $defaultHeaders->addBody('}');
+ $defaultHeaders->addBody('');
+ $defaultHeaders->addBody('return $headers;');
+ }
+
+ public function addPaginateMethod(ClassType $classType): void
+ {
+ $paginate = $classType->addMethod('paginate')
+ ->setPublic()
+ ->setReturnType(JsonApiPaginator::class);
+
+ $paginate->addParameter('request')
+ ->setType(Request::class);
+
+ $paginate->setBody('return new ?($this, $request);', [new Literal('JsonApiPaginator')]);
+ }
+
+ public function addResolveResponseClassMethod(ClassType $classType): void
+ {
+ $classType->addMethod('resolveResponseClass')
+ ->setPublic()
+ ->setReturnType('string')
+ ->setBody('return ?;', [new Literal('TimaticResponse::class')]);
+ }
+}
diff --git a/generator/JsonApiDtoGenerator.php b/generator/JsonApiDtoGenerator.php
new file mode 100644
index 0000000..5057ed8
--- /dev/null
+++ b/generator/JsonApiDtoGenerator.php
@@ -0,0 +1,180 @@
+components) {
+ foreach ($specification->components->schemas as $className => $schema) {
+ $this->generateModelClass(NameHelper::safeClassName($className), $schema);
+ }
+ }
+
+ return $this->generated;
+ }
+
+ protected function generateModelClass(string $className, Schema $schema): PhpFile
+ {
+ $modelName = NameHelper::dtoClassName($className);
+
+ $classType = new ClassType($modelName);
+ $classFile = new PhpFile;
+ $namespace = $classFile
+ ->addNamespace("{$this->config->namespace}\\{$this->config->dtoNamespaceSuffix}");
+
+ // Extend Model instead of Spatie Data
+ $classType->setExtends(Model::class)
+ ->setComment($schema->title ?? '')
+ ->addComment('')
+ ->addComment(Utils::wrapLongLines($schema->description ?? ''));
+
+ // Extract properties from JSON:API structure
+ $properties = $this->extractJsonApiProperties($schema);
+
+ // Add properties to the class
+ foreach ($properties as $propertyName => $propertySpec) {
+ $this->addPropertyToClass($classType, $namespace, $propertyName, $propertySpec);
+ }
+
+ // Add imports
+ $namespace->addUse(Model::class);
+ $namespace->addUse(Property::class);
+
+ $namespace->add($classType);
+
+ $this->generated[$modelName] = $classFile;
+
+ return $classFile;
+ }
+
+ /**
+ * Extract properties from JSON:API schema structure
+ *
+ * @return Schema[]
+ */
+ protected function extractJsonApiProperties(Schema $schema): array
+ {
+ // Check if this is a JSON:API schema with attributes at root level
+ if (isset($schema->properties['attributes'])) {
+ $attributesSchema = $schema->properties['attributes'];
+
+ if ($attributesSchema instanceof Schema && isset($attributesSchema->properties)) {
+ // Return the flattened attributes properties
+ return $attributesSchema->properties;
+ }
+ }
+
+ // Fallback to regular properties if not JSON:API structure
+ return $schema->properties ?? [];
+ }
+
+ protected function addPropertyToClass(
+ ClassType $classType,
+ $namespace,
+ string $propertyName,
+ Schema|Reference $propertySpec
+ ): void {
+ $type = $this->convertOpenApiTypeToPhp($propertySpec);
+ $name = NameHelper::safeVariableName($propertyName);
+
+ // Create public property with #[Property] attribute
+ $property = $classType->addProperty($name)
+ ->setPublic()
+ ->setType($type)
+ ->setNullable(true);
+
+ // Add #[Property] attribute
+ $property->addAttribute(Property::class);
+
+ // Check if this is a datetime field by format OR by naming pattern
+ $isDateTime = ($propertySpec instanceof Schema && $propertySpec->format === 'date-time')
+ || $this->looksLikeDateTimeField($propertyName);
+
+ if ($isDateTime) {
+ $property->addAttribute(DateTime::class);
+ $namespace->addUse(DateTime::class);
+
+ // Change type to Carbon if datetime
+ if (! str_contains($type, 'Carbon')) {
+ $property->setType('null|\\Carbon\\Carbon');
+ }
+ }
+
+ // Add comment with description if available
+ if ($propertySpec instanceof Schema && $propertySpec->description) {
+ $property->addComment($propertySpec->description);
+ }
+ }
+
+ protected function looksLikeDateTimeField(string $name): bool
+ {
+ $patterns = [
+ '_at$', // snake_case: created_at, updated_at, started_at, ended_at, etc.
+ 'At$', // camelCase: createdAt, updatedAt, startedAt, endedAt, etc.
+ '_date$', // snake_case: birth_date, start_date, etc.
+ 'Date$', // camelCase: birthDate, startDate, etc.
+ '^date_', // snake_case: date_created, date_modified, etc.
+ '^date[A-Z]', // camelCase: dateCreated, dateModified, etc.
+ '_time$', // snake_case: start_time, end_time, etc.
+ 'Time$', // camelCase: startTime, endTime, etc.
+ '^time_', // snake_case: time_started, time_ended, etc.
+ '^time[A-Z]', // camelCase: timeStarted, timeEnded, etc.
+ ];
+
+ foreach ($patterns as $pattern) {
+ if (preg_match("/{$pattern}/", $name)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function convertOpenApiTypeToPhp(Schema|Reference $schema): string
+ {
+ if ($schema instanceof Reference) {
+ return Str::afterLast($schema->getReference(), '/');
+ }
+
+ if (is_array($schema->type)) {
+ return collect($schema->type)->map(fn ($type) => $this->mapType($type))->implode('|');
+ }
+
+ return $this->mapType($schema->type, $schema->format);
+ }
+
+ protected function mapType(string $type, ?string $format = null): string
+ {
+ return match ($type) {
+ 'integer' => 'int',
+ 'string' => 'string',
+ 'boolean' => 'bool',
+ 'object' => 'object',
+ 'number' => match ($format) {
+ 'float' => 'float',
+ 'int32', 'int64' => 'int',
+ },
+ 'array' => 'array',
+ 'null' => 'null',
+ };
+ }
+}
diff --git a/generator/JsonApiPestTestGenerator.php b/generator/JsonApiPestTestGenerator.php
new file mode 100644
index 0000000..5292cf9
--- /dev/null
+++ b/generator/JsonApiPestTestGenerator.php
@@ -0,0 +1,245 @@
+generatedCode = $generatedCode;
+
+ // Instantiate test generators with the parsed ApiSpecification and GeneratedCode
+ $this->collectionTestGenerator = new CollectionRequestTestGenerator($specification, $generatedCode);
+ $this->mutationTestGenerator = new MutationRequestTestGenerator($specification, $generatedCode);
+ $this->singularGetTestGenerator = new SingularGetRequestTestGenerator($specification, $generatedCode);
+ $this->deleteTestGenerator = new DeleteRequestTestGenerator($specification, $generatedCode);
+
+ // Call parent to continue normal processing
+ return parent::process($config, $specification, $generatedCode);
+ }
+
+ /**
+ * Skip generating Pest.php - we have a custom version
+ */
+ protected function shouldGeneratePestFile(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Skip generating TestCase.php - we have a custom version
+ */
+ protected function shouldGenerateTestCaseFile(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Filter out PUT requests and endpoints without DTOs
+ */
+ protected function shouldIncludeEndpoint(Endpoint $endpoint): bool
+ {
+ if ($endpoint->method->isPut()) {
+ return false;
+ }
+
+ // Skip endpoints without DTOs (endpoints that don't return data)
+ if (! $this->hasDtoForEndpoint($endpoint)) {
+ return false;
+ }
+
+ // Skip endpoints where DTO has no properties (will fail during mock data generation)
+ if (! $this->hasValidDtoProperties($endpoint)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if endpoint has a DTO with valid properties for test generation
+ */
+ protected function hasValidDtoProperties(Endpoint $endpoint): bool
+ {
+ try {
+ // Try to generate mock data - will throw if DTO has no properties
+ if ($this->collectionTestGenerator->isCollectionRequest($endpoint)) {
+ $this->collectionTestGenerator->generateMockData($endpoint);
+ } elseif ($this->singularGetTestGenerator->isSingularGetRequest($endpoint)) {
+ $this->singularGetTestGenerator->generateMockData($endpoint);
+ }
+
+ return true;
+ } catch (\RuntimeException $e) {
+ // DTO has no properties - skip test generation
+ if (str_contains($e->getMessage(), 'has no properties')) {
+ echo " ⊘ Skipping {$endpoint->name}: ".$e->getMessage()."\n";
+
+ return false;
+ }
+ // Re-throw other exceptions
+ throw $e;
+ }
+ }
+
+ /**
+ * Check if a DTO exists for the endpoint
+ */
+ protected function hasDtoForEndpoint(Endpoint $endpoint): bool
+ {
+ $dtoClassName = $this->getDtoClassName($endpoint);
+
+ // Check if this DTO was generated in the current run
+ return array_key_exists($dtoClassName, $this->generatedCode->dtoClasses);
+ }
+
+ /**
+ * Use our custom JSON:API test stub
+ */
+ protected function getTestStubPath(): string
+ {
+ return __DIR__.'/TestGenerators/stubs/pest-resource-test.stub';
+ }
+
+ /**
+ * Use our custom JSON:API test function stub
+ */
+ protected function getTestFunctionStubPath(Endpoint $endpoint): string
+ {
+ // Delegate to CollectionRequestTestGenerator for collection requests
+ if ($this->collectionTestGenerator->isCollectionRequest($endpoint)) {
+ return $this->collectionTestGenerator->getStubPath();
+ }
+
+ // Delegate to MutationRequestTestGenerator for mutation requests
+ if ($this->mutationTestGenerator->isMutationRequest($endpoint)) {
+ return $this->mutationTestGenerator->getStubPath();
+ }
+
+ // Delegate to SingularGetRequestTestGenerator for singular GET requests
+ if ($this->singularGetTestGenerator->isSingularGetRequest($endpoint)) {
+ return $this->singularGetTestGenerator->getStubPath();
+ }
+
+ // Delegate to DeleteRequestTestGenerator for DELETE requests
+ if ($this->deleteTestGenerator->isDeleteRequest($endpoint)) {
+ return $this->deleteTestGenerator->getStubPath();
+ }
+
+ throw new \RuntimeException('Unmatched request type');
+ }
+
+ /**
+ * Add "Request" suffix to match JsonApiRequestGenerator behavior
+ */
+ protected function getRequestClassName(Endpoint $endpoint): string
+ {
+ $className = NameHelper::requestClassName($endpoint->name ?: NameHelper::pathBasedName($endpoint));
+
+ if (! str_ends_with($className, 'Request')) {
+ $className .= 'Request';
+ }
+
+ return $className;
+ }
+
+ protected function getMethodName(Endpoint $endpoint, string $requestClassName): string
+ {
+ // Strip "Request" suffix if present to match actual Resource method names
+ $methodBaseName = str_ends_with($requestClassName, 'Request')
+ ? substr($requestClassName, 0, -7)
+ : $requestClassName;
+
+ return NameHelper::safeVariableName($methodBaseName);
+ }
+
+ /**
+ * Place tests in tests/Requests/ directory
+ */
+ protected function getTestPath(string $resourceName): string
+ {
+ return "tests/Requests/{$resourceName}Test.php";
+ }
+
+ /**
+ * Hook: Transform path parameter names (e.g., budget -> budgetId)
+ */
+ protected function getTestParameterName(Parameter $parameter, Endpoint $endpoint): string
+ {
+ $name = parent::getTestParameterName($parameter, $endpoint);
+
+ // Check if this is a path parameter
+ if (in_array($parameter, $endpoint->pathParameters, true)) {
+ return $name.'Id';
+ }
+
+ return $name;
+ }
+
+ /**
+ * Hook: Replace additional stub variables
+ */
+ protected function replaceAdditionalStubVariables(
+ string $functionStub,
+ Endpoint $endpoint,
+ string $resourceName,
+ string $requestClassName
+ ): string {
+ // Delegate to CollectionRequestTestGenerator for collection requests
+ if ($this->collectionTestGenerator->isCollectionRequest($endpoint)) {
+ return $this->collectionTestGenerator->replaceStubVariables($functionStub, $endpoint);
+ }
+
+ // Delegate to MutationRequestTestGenerator for mutation requests
+ if ($this->mutationTestGenerator->isMutationRequest($endpoint)) {
+ return $this->mutationTestGenerator->replaceStubVariables($functionStub, $endpoint);
+ }
+
+ // Delegate to SingularGetRequestTestGenerator for singular GET requests
+ if ($this->singularGetTestGenerator->isSingularGetRequest($endpoint)) {
+ return $this->singularGetTestGenerator->replaceStubVariables($functionStub, $endpoint);
+ }
+
+ // Delegate to DeleteRequestTestGenerator for DELETE requests
+ if ($this->deleteTestGenerator->isDeleteRequest($endpoint)) {
+ return $this->deleteTestGenerator->replaceStubVariables($functionStub, $endpoint);
+ }
+
+ throw new \RuntimeException('Unmatched request type');
+ }
+}
diff --git a/generator/JsonApiRequestGenerator.php b/generator/JsonApiRequestGenerator.php
new file mode 100644
index 0000000..67898a3
--- /dev/null
+++ b/generator/JsonApiRequestGenerator.php
@@ -0,0 +1,226 @@
+specification = $specification;
+
+ return parent::generate($specification);
+ }
+
+ /**
+ * Hook: Filter out PUT requests - not supported in JSON:API
+ */
+ protected function shouldIncludeEndpoint(Endpoint $endpoint): bool
+ {
+ return ! $endpoint->method->isPut();
+ }
+
+ /**
+ * Hook: Add "Request" suffix to class names
+ */
+ protected function getRequestClassName(Endpoint $endpoint): string
+ {
+ $className = parent::getRequestClassName($endpoint);
+
+ if (! str_ends_with($className, 'Request')) {
+ $className .= 'Request';
+ }
+
+ return $className;
+ }
+
+ /**
+ * Hook: Transform path parameter names (e.g., budget -> budgetId)
+ */
+ protected function getConstructorParameterName(string $originalName, bool $isPathParam = false): string
+ {
+ if ($isPathParam) {
+ return $originalName.'Id';
+ }
+
+ return $originalName;
+ }
+
+ /**
+ * Hook: Customize request class for collection requests
+ */
+ protected function customizeRequestClass(ClassType $classType, $namespace, Endpoint $endpoint): void
+ {
+ if ($this->isCollectionRequest($endpoint)) {
+ // Add Paginatable interface to all collection requests
+ $namespace->addUse(Paginatable::class);
+ $classType->addImplement(Paginatable::class);
+
+ // Add HasFilters trait if collection has filter parameters in the endpoint
+ if ($this->hasFilterParameters($endpoint)) {
+ $namespace->addUse(HasFilters::class);
+ $classType->addTrait(HasFilters::class);
+ }
+ }
+
+ // Add hydration support to GET, POST, and PATCH requests
+ if ($this->shouldHaveHydration($endpoint)) {
+ $this->addHydrationSupport($classType, $namespace, $endpoint);
+ }
+ }
+
+ /**
+ * Hook: Customize constructor for mutation requests
+ */
+ protected function customizeConstructor($classConstructor, ClassType $classType, $namespace, Endpoint $endpoint): void
+ {
+ if (! $this->isMutationRequest($endpoint)) {
+ return;
+ }
+
+ $namespace->addUse(Model::class);
+
+ $dataParam = new Parameter(
+ type: '\\Timatic\\SDK\\Hydration\\Model|array|null',
+ nullable: true,
+ name: 'data',
+ description: 'Request data',
+ );
+
+ MethodGeneratorHelper::addParameterAsPromotedProperty($classConstructor, $dataParam);
+
+ $classType->addMethod('defaultBody')
+ ->setProtected()
+ ->setReturnType('array')
+ ->addBody('return $this->data ? $this->data->toJsonApi() : [];');
+ }
+
+ /**
+ * Hook: Filter out filter* query parameters (handled by HasFilters trait)
+ */
+ protected function shouldIncludeQueryParameter(string $paramName): bool
+ {
+ return ! str_starts_with($paramName, 'filter');
+ }
+
+ /**
+ * Hook: Generate defaultQuery method with custom JSON:API logic
+ */
+ protected function generateDefaultQueryMethod(\Nette\PhpGenerator\ClassType $classType, $namespace, array $queryParams, Endpoint $endpoint): void
+ {
+ // If we have any query parameters (likely just 'include'), use array_filter
+ if (! empty($queryParams)) {
+ $classType->addMethod('defaultQuery')
+ ->setProtected()
+ ->setReturnType('array')
+ ->addBody("return array_filter(['include' => \$this->include]);");
+ }
+ }
+
+ // Helper methods for JSON:API logic
+
+ protected function isMutationRequest(Endpoint $endpoint): bool
+ {
+ // Only POST and PATCH are supported mutation methods
+ return $endpoint->method->isPost()
+ || $endpoint->method->isPatch();
+ }
+
+ protected function isCollectionRequest(Endpoint $endpoint): bool
+ {
+ // Collection requests are GET requests without path parameters
+ return $endpoint->method->isGet() && empty($endpoint->pathParameters);
+ }
+
+ protected function hasFilterParameters(Endpoint $endpoint): bool
+ {
+ foreach ($endpoint->queryParameters as $param) {
+ if (str_starts_with($param->name, 'filter')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine if request should have hydration support
+ */
+ protected function shouldHaveHydration(Endpoint $endpoint): bool
+ {
+ $schemas = array_keys($this->specification->components->schemas);
+ $dtoName = $this->getDtoClassName($endpoint);
+
+ if (! in_array($dtoName, $schemas)) {
+ // There is no schema, so also no DTO to hydrate
+ return false;
+ }
+
+ // Add hydration to GET, POST, and PATCH requests
+ return $endpoint->method->isGet()
+ || $endpoint->method->isPost()
+ || $endpoint->method->isPatch();
+ }
+
+ /**
+ * Add hydration support to request class
+ */
+ protected function addHydrationSupport(ClassType $classType, $namespace, Endpoint $endpoint): void
+ {
+ // Determine DTO class name from endpoint
+ $dtoClassName = $this->getDtoClassName($endpoint);
+
+ // Add imports
+ $namespace->addUse(Hydrator::class);
+ $namespace->addUse(Response::class);
+ $namespace->addUse("Timatic\\SDK\\Dto\\{$dtoClassName}");
+
+ // Add $model property - use the imported class name with ::class
+ $classType->addProperty('model')
+ ->setProtected()
+ ->setValue(new \Nette\PhpGenerator\Literal("{$dtoClassName}::class"));
+
+ // Add createDtoFromResponse method
+ $method = $classType->addMethod('createDtoFromResponse')
+ ->setReturnType('mixed');
+
+ $param = $method->addParameter('response');
+ $param->setType(Response::class);
+
+ // Use appropriate hydration method based on request type
+ if ($this->isCollectionRequest($endpoint)) {
+ // Collection: use hydrateCollection
+ $method->addBody('return Hydrator::hydrateCollection(');
+ $method->addBody(' $this->model,');
+ $method->addBody(' $response->json(\'data\'),');
+ $method->addBody(' $response->json(\'included\')');
+ $method->addBody(');');
+ } else {
+ // Single resource: use hydrate
+ $method->addBody('return Hydrator::hydrate(');
+ $method->addBody(' $this->model,');
+ $method->addBody(' $response->json(\'data\'),');
+ $method->addBody(' $response->json(\'included\')');
+ $method->addBody(');');
+ }
+ }
+}
diff --git a/generator/JsonApiResourceGenerator.php b/generator/JsonApiResourceGenerator.php
new file mode 100644
index 0000000..1c68f83
--- /dev/null
+++ b/generator/JsonApiResourceGenerator.php
@@ -0,0 +1,104 @@
+method->isPut();
+ }
+
+ /**
+ * Hook: Add "Request" suffix to request class names
+ */
+ protected function getRequestClassName(Endpoint $endpoint): string
+ {
+ $className = parent::getRequestClassName($endpoint);
+
+ if (! str_ends_with($className, 'Request')) {
+ $className .= 'Request';
+ }
+
+ return $className;
+ }
+
+ /**
+ * Hook: Strip "Request" suffix from method names
+ */
+ protected function getMethodName(Endpoint $endpoint, string $requestClassName): string
+ {
+ // Strip "Request" suffix if present to get clean method names
+ $methodBaseName = str_ends_with($requestClassName, 'Request')
+ ? substr($requestClassName, 0, -7)
+ : $requestClassName;
+
+ return \Crescat\SaloonSdkGenerator\Helpers\NameHelper::safeVariableName($methodBaseName);
+ }
+
+ /**
+ * Hook: Transform path parameter names (e.g., budget -> budgetId)
+ */
+ protected function getResourceParameterName(Parameter $parameter, bool $isPathParam): string
+ {
+ if ($isPathParam) {
+ return $parameter->name.'Id';
+ }
+
+ return $parameter->name;
+ }
+
+ /**
+ * Hook: Customize resource method for mutation requests
+ */
+ protected function customizeResourceMethod(\Nette\PhpGenerator\Method $method, $namespace, array &$args, Endpoint $endpoint): void
+ {
+ if (! ($endpoint->method->isPost() || $endpoint->method->isPatch())) {
+ return;
+ }
+
+ $namespace->addUse(Model::class);
+
+ $dataParam = new Parameter(
+ type: 'Timatic\\SDK\\Hydration\\Model|array|null',
+ nullable: true,
+ name: 'data',
+ description: 'Request data',
+ );
+
+ $this->addPropertyToMethod($method, $dataParam);
+ $args[] = new \Nette\PhpGenerator\Literal('$data');
+ }
+
+ protected function addPropertyToMethod(Method $method, Parameter $parameter): Method
+ {
+ $name = NameHelper::safeVariableName($parameter->name);
+
+ if (str_contains($parameter->name, 'filter')) {
+ return $method;
+ }
+
+ $param = $method
+ ->addParameter($name)
+ ->setType($parameter->type)
+ ->setNullable($parameter->nullable);
+
+ if ($parameter->nullable) {
+ $param->setDefaultValue(null);
+ }
+
+ return $method;
+ }
+}
diff --git a/generator/TestGenerators/CollectionRequestTestGenerator.php b/generator/TestGenerators/CollectionRequestTestGenerator.php
new file mode 100644
index 0000000..20f6b67
--- /dev/null
+++ b/generator/TestGenerators/CollectionRequestTestGenerator.php
@@ -0,0 +1,233 @@
+specification = $specification;
+ $this->generatedCode = $generatedCode;
+ }
+
+ /**
+ * Check if endpoint is a GET collection request (implements Paginatable)
+ */
+ public function isCollectionRequest(Endpoint $endpoint): bool
+ {
+ // GET requests without path parameters are collection requests
+ return $endpoint->method->isGet() && empty($endpoint->pathParameters);
+ }
+
+ /**
+ * Get the stub path for collection request tests
+ */
+ public function getStubPath(): string
+ {
+ return __DIR__.'/stubs/pest-collection-request-test-func.stub';
+ }
+
+ /**
+ * Replace stub variables with collection-specific content
+ */
+ public function replaceStubVariables(string $functionStub, Endpoint $endpoint): string
+ {
+ $filterData = $this->generateFilterChainWithData($endpoint);
+ $functionStub = str_replace('{{ filterChain }}', $filterData['chain'], $functionStub);
+
+ // Only include filter assertions block if there are filters
+ if (! empty($filterData['assertions'])) {
+ $filterAssertionBlock = $this->generateFilterAssertionBlock($filterData['assertions']);
+ $functionStub = str_replace('{{ filterAssertionBlock }}', $filterAssertionBlock, $functionStub);
+ } else {
+ $functionStub = str_replace('{{ filterAssertionBlock }}', '', $functionStub);
+ }
+
+ // Add non-filter query parameters (like 'include')
+ $nonFilterParams = $this->getNonFilterQueryParameters($endpoint);
+ $functionStub = str_replace('{{ nonFilterParams }}', $nonFilterParams, $functionStub);
+
+ $mockData = $this->generateMockData($endpoint);
+ $mockResponseBody = $this->formatArrayAsPhp($mockData);
+
+ $functionStub = preg_replace(
+ "/MockResponse::fixture\('[^']+'\)/",
+ "MockResponse::make($mockResponseBody, 200)",
+ $functionStub
+ );
+
+ // Generate DTO assertions based on mock data
+ $mockData = $this->generateMockData($endpoint);
+ $dtoAssertions = $this->generateDtoAssertions($mockData);
+
+ // If no valid assertions (comments only), remove the DTO validation block entirely
+ if (str_starts_with(trim($dtoAssertions), '//')) {
+ // Remove the entire DTO validation block
+ $pattern = '/(.*\$response->status\(\)\)->toBe\(200\);.*?)(\n\s*\$dtoCollection = \$response->dto\(\);.*?{{ dtoAssertions }};)/s';
+ $functionStub = preg_replace($pattern, '$1', $functionStub);
+ } else {
+ $functionStub = str_replace('{{ dtoAssertions }}', $dtoAssertions, $functionStub);
+ }
+
+ return $functionStub;
+ }
+
+ /**
+ * Generate mock data for collection response
+ */
+ public function generateMockData(Endpoint $endpoint): array
+ {
+ // Get DTO class name from endpoint
+ $dtoClassName = $this->getDtoClassName($endpoint);
+
+ // Generate mock data based on DTO - must have properties
+ $attributes = $this->generateMockAttributesFromDto($dtoClassName);
+ if (empty($attributes) || $attributes === ['name' => 'Mock value']) {
+ throw new \RuntimeException("DTO '{$dtoClassName}' has no properties - skipping test generation");
+ }
+
+ $resourceType = $this->getResourceTypeFromEndpoint($endpoint);
+
+ // Generate 2-3 items for collections
+ return [
+ 'data' => [
+ [
+ 'type' => $resourceType,
+ 'id' => 'mock-id-1',
+ 'attributes' => $attributes,
+ ],
+ [
+ 'type' => $resourceType,
+ 'id' => 'mock-id-2',
+ 'attributes' => $attributes,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Generate the complete filter assertion block
+ */
+ protected function generateFilterAssertionBlock(string $assertions): string
+ {
+ $stub = file_get_contents(__DIR__.'/stubs/pest-filter-assertion-block.stub');
+
+ return str_replace('{{ filterAssertions }}', $assertions, $stub);
+ }
+
+ /**
+ * Generate a fluent filter chain with 2-3 representative examples and their assertions
+ */
+ protected function generateFilterChainWithData(Endpoint $endpoint): array
+ {
+ $filters = [];
+ $assertions = [];
+ $maxFilters = 3;
+ $seenProperties = [];
+
+ // Extract filter parameters from query parameters
+ foreach ($endpoint->queryParameters as $parameter) {
+ if (count($filters) >= $maxFilters) {
+ break;
+ }
+
+ // Skip non-filter parameters
+ if (! str_starts_with($parameter->name, 'filter[')) {
+ continue;
+ }
+
+ // Parse filter[property][operator] or filter[property]
+ preg_match('/filter\[([^\]]+)\](?:\[([^\]]+)\])?/', $parameter->name, $matches);
+ $property = $matches[1] ?? null;
+ $operator = $matches[2] ?? null;
+
+ if (! $property) {
+ continue;
+ }
+
+ // Skip if we already have a filter for this property (avoid duplicates with operators)
+ if (isset($seenProperties[$property])) {
+ continue;
+ }
+
+ // Only add filters without operators (simpler)
+ if (! $operator && count($filters) < $maxFilters) {
+ $value = $this->formatAsCode($this->generateValue($property));
+ $filters[] = "->filter('{$property}', {$value})";
+
+ // Generate assertion for this filter
+ $assertions[] = $this->generateFilterAssertion($property, $value);
+
+ $seenProperties[$property] = true;
+ }
+ }
+
+ return [
+ 'chain' => implode("\n\t\t", $filters),
+ 'assertions' => implode("\n\t\t", $assertions),
+ ];
+ }
+
+ /**
+ * Generate a filter assertion for the given property and value
+ */
+ protected function generateFilterAssertion(string $property, string $value): string
+ {
+ // Handle both string and boolean values
+ if ($value === 'true' || $value === 'false') {
+ // Boolean values
+ return "expect(\$query)->toHaveKey('filter[{$property}]', {$value});";
+ }
+
+ // String values - remove quotes for the assertion
+ $assertionValue = trim($value, "'");
+
+ return "expect(\$query)->toHaveKey('filter[{$property}]', '{$assertionValue}');";
+ }
+
+ /**
+ * Get non-filter query parameters (like 'include')
+ */
+ protected function getNonFilterQueryParameters(Endpoint $endpoint): string
+ {
+ $params = [];
+
+ foreach ($endpoint->queryParameters as $parameter) {
+ if (! str_starts_with($parameter->name, 'filter[')) {
+ $paramName = NameHelper::safeVariableName($parameter->name);
+ $value = match ($parameter->type) {
+ 'string' => "'test string'",
+ 'int', 'integer' => '123',
+ 'bool', 'boolean' => 'true',
+ 'array' => '[]',
+ default => 'null',
+ };
+ $params[] = "{$paramName}: {$value}";
+ }
+ }
+
+ return implode(', ', $params);
+ }
+}
diff --git a/generator/TestGenerators/DeleteRequestTestGenerator.php b/generator/TestGenerators/DeleteRequestTestGenerator.php
new file mode 100644
index 0000000..b60e791
--- /dev/null
+++ b/generator/TestGenerators/DeleteRequestTestGenerator.php
@@ -0,0 +1,47 @@
+specification = $specification;
+ $this->generatedCode = $generatedCode;
+ }
+
+ /**
+ * Check if endpoint is a DELETE request
+ */
+ public function isDeleteRequest(Endpoint $endpoint): bool
+ {
+ return $endpoint->method->isDelete();
+ }
+
+ /**
+ * Get the stub path for DELETE request tests
+ */
+ public function getStubPath(): string
+ {
+ return __DIR__.'/stubs/pest-delete-request-test-func.stub';
+ }
+
+ /**
+ * Replace stub variables (DELETE requests don't need custom replacements)
+ */
+ public function replaceStubVariables(string $functionStub, Endpoint $endpoint): string
+ {
+ // DELETE requests use the standard stub without custom replacements
+ return $functionStub;
+ }
+}
diff --git a/generator/TestGenerators/MutationRequestTestGenerator.php b/generator/TestGenerators/MutationRequestTestGenerator.php
new file mode 100644
index 0000000..6189367
--- /dev/null
+++ b/generator/TestGenerators/MutationRequestTestGenerator.php
@@ -0,0 +1,231 @@
+specification = $specification;
+ $this->generatedCode = $generatedCode;
+ }
+
+ /**
+ * Check if endpoint is a mutation request (POST or PATCH)
+ */
+ public function isMutationRequest(Endpoint $endpoint): bool
+ {
+ return $endpoint->method->isPost() || $endpoint->method->isPatch();
+ }
+
+ /**
+ * Get the stub path for mutation request tests
+ */
+ public function getStubPath(): string
+ {
+ return __DIR__.'/stubs/pest-mutation-test-func.stub';
+ }
+
+ /**
+ * Replace stub variables with mutation-specific content
+ */
+ public function replaceStubVariables(string $functionStub, Endpoint $endpoint): string
+ {
+ // Generate DTO instantiation code
+ $dtoInstantiation = $this->generateDtoInstantiation($endpoint);
+ $functionStub = str_replace('{{ dtoInstantiation }}', $dtoInstantiation, $functionStub);
+
+ // Generate body validation code
+ $bodyValidation = $this->generateBodyValidation($endpoint);
+ $functionStub = str_replace('{{ bodyValidation }}', $bodyValidation, $functionStub);
+
+ // Generate method arguments (including $dto)
+ $methodArguments = $this->generateMethodArguments($endpoint);
+ $functionStub = str_replace('{{ mutationMethodArguments }}', $methodArguments, $functionStub);
+
+ return $functionStub;
+ }
+
+ /**
+ * Generate method arguments for resource method call
+ */
+ protected function generateMethodArguments(Endpoint $endpoint): string
+ {
+ $args = [];
+
+ // Add path parameters first (e.g., budgetId for PATCH)
+ foreach ($endpoint->pathParameters as $param) {
+ $paramName = NameHelper::safeVariableName($param->name);
+ // Add 'Id' suffix if not already present
+ if (! str_ends_with($paramName, 'Id')) {
+ $paramName .= 'Id';
+ }
+ $args[] = "{$paramName}: 'test string'";
+ }
+
+ // Add $dto parameter last - use named argument if there are path params
+ if (empty($endpoint->pathParameters)) {
+ $args[] = '$dto';
+ } else {
+ $args[] = 'data: $dto';
+ }
+
+ return implode(', ', $args);
+ }
+
+ /**
+ * Generate DTO instantiation code with sample data
+ */
+ protected function generateDtoInstantiation(Endpoint $endpoint): string
+ {
+ $dtoClassName = $this->getDtoClassName($endpoint);
+ $properties = $this->generateDtoProperties($endpoint);
+
+ $lines = [];
+ $lines[] = " \$dto = new \\Timatic\\SDK\\Dto\\{$dtoClassName};";
+ $lines[] = $properties;
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Filter properties for test generation (skip timestamps, check DateTime)
+ *
+ * @return array
+ */
+ protected function getFilteredPropertiesForTest(Endpoint $endpoint): array
+ {
+ $dtoClassName = $this->getDtoClassName($endpoint);
+ $properties = $this->getDtoPropertiesFromGeneratedCode($dtoClassName);
+
+ $filtered = [];
+
+ foreach ($properties as $propInfo) {
+ $propName = $propInfo['name'];
+
+ // Skip read-only/auto-managed fields
+ if (in_array($propName, ['id', 'createdAt', 'updatedAt', 'deletedAt'])) {
+ continue;
+ }
+
+ // Check if this is a Carbon/DateTime field
+ $isDateTime = $propInfo['type'] && str_contains($propInfo['type'], 'Carbon');
+
+ $filtered[] = [
+ 'name' => $propName,
+ 'type' => $propInfo['type'],
+ 'isDateTime' => $isDateTime,
+ ];
+ }
+
+ return array_slice($filtered, 0, 4);
+ }
+
+ /**
+ * Generate DTO property assignments
+ */
+ protected function generateDtoProperties(Endpoint $endpoint): string
+ {
+ $filteredProperties = $this->getFilteredPropertiesForTest($endpoint);
+
+ $lines = [];
+
+ foreach ($filteredProperties as $propInfo) {
+ $propName = $propInfo['name'];
+
+ if ($propInfo['isDateTime']) {
+ // Generate Carbon::parse() for DateTime fields
+ $dateString = $this->generateValue($propName, $propInfo['type']);
+ $lines[] = " \$dto->{$propName} = \\Carbon\\Carbon::parse('{$dateString}');";
+ } else {
+ $value = $this->formatAsCode($this->generateValue($propName, $propInfo['type']));
+ $lines[] = " \$dto->{$propName} = {$value};";
+ }
+ }
+
+ // Fallback if no properties after filtering
+ if (empty($lines)) {
+ return " \$dto->name = 'test value';";
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Generate body validation code
+ */
+ protected function generateBodyValidation(Endpoint $endpoint): string
+ {
+ $resourceType = $this->getResourceTypeFromEndpoint($endpoint);
+
+ $attributeValidations = $this->generateAttributeValidationsFromDto($endpoint);
+
+ $lines = [];
+
+ $lines[] = ' $mockClient->assertSent(function (Request $request) {';
+ $lines[] = ' expect($request->body()->all())';
+ $lines[] = " ->toHaveKey('data')";
+
+ // Generate attribute validations
+ if ($attributeValidations) {
+ $lines[] = " ->data->type->toBe('{$resourceType}')";
+ $lines[] = ' ->data->attributes->scoped(fn ($attributes) => $attributes';
+ $lines[] = $attributeValidations;
+ $lines[] = ' );';
+ } else {
+ // No attributes to validate, just close the chain
+ $lines[] = " ->data->type->toBe('{$resourceType}');";
+ }
+
+ $lines[] = '';
+ $lines[] = ' return true;';
+ $lines[] = ' });';
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Generate attribute validation chain from DTO properties
+ */
+ protected function generateAttributeValidationsFromDto(Endpoint $endpoint): string
+ {
+ $filteredProperties = $this->getFilteredPropertiesForTest($endpoint);
+ $lines = [];
+
+ foreach ($filteredProperties as $propInfo) {
+ $propName = $propInfo['name'];
+
+ if ($propInfo['isDateTime']) {
+ // Generate toEqual(new \Carbon\Carbon(...)) assertion for DateTime fields
+ $dateString = $this->generateValue($propName, $propInfo['type']);
+ $lines[] = " ->{$propName}->toEqual(new \\Carbon\\Carbon('{$dateString}'))";
+ } else {
+ $value = $this->formatAsCode($this->generateValue($propName, $propInfo['type']));
+ $assertionValue = $this->formatValueForAssertion($value);
+ $lines[] = " ->{$propName}->toBe({$assertionValue})";
+ }
+ }
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/generator/TestGenerators/SingularGetRequestTestGenerator.php b/generator/TestGenerators/SingularGetRequestTestGenerator.php
new file mode 100644
index 0000000..a0655a9
--- /dev/null
+++ b/generator/TestGenerators/SingularGetRequestTestGenerator.php
@@ -0,0 +1,103 @@
+specification = $specification;
+ $this->generatedCode = $generatedCode;
+ }
+
+ /**
+ * Check if endpoint is a singular GET request (GET with path parameters)
+ */
+ public function isSingularGetRequest(Endpoint $endpoint): bool
+ {
+ // GET requests WITH path parameters are singular GET requests
+ return $endpoint->method->isGet() && ! empty($endpoint->pathParameters);
+ }
+
+ /**
+ * Get the stub path for singular GET request tests
+ */
+ public function getStubPath(): string
+ {
+ return __DIR__.'/stubs/pest-singular-get-request-test-func.stub';
+ }
+
+ /**
+ * Replace stub variables with singular GET-specific content
+ */
+ public function replaceStubVariables(string $functionStub, Endpoint $endpoint): string
+ {
+ // Generate mock response body
+ $mockData = $this->generateMockData($endpoint);
+ $mockResponseBody = $this->formatArrayAsPhp($mockData);
+
+ $functionStub = str_replace(
+ '{{ mockResponse }}',
+ "MockResponse::make($mockResponseBody, 200)",
+ $functionStub
+ );
+
+ // Generate DTO assertions based on mock data
+ $mockData = $this->generateMockData($endpoint);
+ $dtoAssertions = $this->generateDtoAssertions($mockData);
+
+ // If no valid assertions (comments only), remove the DTO validation block entirely
+ if (str_starts_with(trim($dtoAssertions), '//')) {
+ // Remove the entire DTO validation block
+ $pattern = '/(.*\$response->status\(\)\)->toBe\(200\);.*?)(\n\s*\$dto = \$response->dto\(\);.*?{{ dtoAssertions }};)/s';
+ $functionStub = preg_replace($pattern, '$1', $functionStub);
+ } else {
+ $functionStub = str_replace('{{ dtoAssertions }}', $dtoAssertions, $functionStub);
+ }
+
+ return $functionStub;
+ }
+
+ /**
+ * Generate mock data for singular GET response
+ */
+ public function generateMockData(Endpoint $endpoint): array
+ {
+ // Get DTO class name from endpoint
+ $dtoClassName = $this->getDtoClassName($endpoint);
+
+ // Generate mock data based on DTO - must have properties
+ $attributes = $this->generateMockAttributesFromDto($dtoClassName);
+
+ $resourceType = $this->getResourceTypeFromEndpoint($endpoint);
+
+ return [
+ 'data' => [
+ 'type' => $resourceType,
+ 'id' => 'mock-id-123',
+ 'attributes' => $attributes,
+ ],
+ ];
+ }
+}
diff --git a/generator/TestGenerators/Traits/DtoAssertions.php b/generator/TestGenerators/Traits/DtoAssertions.php
new file mode 100644
index 0000000..a70578c
--- /dev/null
+++ b/generator/TestGenerators/Traits/DtoAssertions.php
@@ -0,0 +1,211 @@
+ $value) {
+ // Skip arrays completely
+ if (is_array($value)) {
+ continue;
+ }
+
+ $assertion = $this->generateAssertionForValue($key, $value);
+ $assertions[] = $assertion;
+ }
+
+ // If no valid assertions after filtering, return comment
+ if (empty($assertions)) {
+ return ' // No simple attributes to validate (arrays skipped)';
+ }
+
+ return implode("\n", $assertions);
+ }
+
+ /**
+ * Get DTO properties from generated code
+ *
+ * @return array
+ */
+ protected function getDtoPropertiesFromGeneratedCode(string $dtoClassName): array
+ {
+ // Check if DTO exists in generated code
+ if (! isset($this->generatedCode->dtoClasses[$dtoClassName])) {
+ return [];
+ }
+
+ $phpFile = $this->generatedCode->dtoClasses[$dtoClassName];
+ $properties = [];
+
+ // Get the first namespace in the file
+ $namespace = array_values($phpFile->getNamespaces())[0] ?? null;
+ if (! $namespace) {
+ return [];
+ }
+
+ // Get the first class in the namespace
+ $classType = array_values($namespace->getClasses())[0] ?? null;
+ if (! $classType) {
+ return [];
+ }
+
+ // Extract properties from the class
+ foreach ($classType->getProperties() as $property) {
+ // Skip static properties
+ if ($property->isStatic()) {
+ continue;
+ }
+
+ $type = $property->getType();
+ $typeName = null;
+
+ if ($type) {
+ $typeName = (string) $type;
+ }
+
+ $properties[$property->getName()] = [
+ 'name' => $property->getName(),
+ 'type' => $typeName,
+ ];
+ }
+
+ return $properties;
+ }
+
+ /**
+ * Generate mock attributes from DTO properties
+ */
+ protected function generateMockAttributesFromDto(string $dtoClassName): array
+ {
+ $properties = $this->getDtoPropertiesFromGeneratedCode($dtoClassName);
+
+ if (empty($properties)) {
+ return ['name' => 'Mock value'];
+ }
+
+ $attributes = [];
+
+ foreach ($properties as $propInfo) {
+ $propName = $propInfo['name'];
+
+ // Skip ID and timestamps - these are typically read-only
+ if (in_array($propName, ['id', 'createdAt', 'updatedAt', 'deletedAt'])) {
+ continue;
+ }
+
+ $attributes[$propName] = $this->generateMockValueForDtoProperty($propName, $propInfo['type']);
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Generate a mock value for a DTO property based on its type
+ */
+ protected function generateMockValueForDtoProperty(string $propertyName, string $typeName): mixed
+ {
+ // Normalize type name (remove nullable prefix)
+ $typeName = ltrim($typeName, '?');
+
+ // DateTime fields
+ if (str_contains($typeName, 'Carbon') || str_contains($typeName, 'DateTime')) {
+ return '2025-11-22T10:40:04.065Z';
+ }
+
+ // Type-based generation (handle explicit types first)
+ if ($typeName === 'bool') {
+ return true;
+ }
+
+ if ($typeName === 'int') {
+ return 42;
+ }
+
+ if ($typeName === 'float') {
+ return 3.14;
+ }
+
+ if ($typeName === 'array') {
+ return [];
+ }
+
+ // String type - apply name-based heuristics
+ if ($typeName === 'string') {
+ // ID fields
+ if (str_ends_with($propertyName, 'Id')) {
+ return 'mock-id-123';
+ }
+
+ // Email fields
+ if (str_contains($propertyName, 'email') || str_contains($propertyName, 'Email')) {
+ return 'test@example.com';
+ }
+
+ return 'Mock value';
+ }
+
+ // This should never be reached with the current OpenAPI spec
+ throw new \RuntimeException("Unexpected type '{$typeName}' for property '{$propertyName}'");
+ }
+
+ /**
+ * Generate an assertion for a specific attribute value
+ */
+ protected function generateAssertionForValue(string $key, mixed $value): string
+ {
+ // Handle different value types
+ if (is_bool($value)) {
+ $expected = $value ? 'true' : 'false';
+
+ return " ->{$key}->toBe({$expected})";
+ }
+
+ if (is_int($value)) {
+ return " ->{$key}->toBe({$value})";
+ }
+
+ if (is_null($value)) {
+ return " ->{$key}->toBeNull()";
+ }
+
+ // Check if it's a datetime string
+ if (is_string($value) && $this->isDateTimeString($value)) {
+ return " ->{$key}->toEqual(new Carbon(\"{$value}\"))";
+ }
+
+ // Default: string value
+ $escapedValue = addslashes($value);
+
+ return " ->{$key}->toBe(\"{$escapedValue}\")";
+ }
+
+ /**
+ * Check if a string is a datetime format
+ */
+ protected function isDateTimeString(string $value): bool
+ {
+ // Check for ISO 8601 format (e.g., 2025-11-22T10:40:04.065Z)
+ return (bool) preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $value);
+ }
+}
diff --git a/generator/TestGenerators/Traits/DtoHelperTrait.php b/generator/TestGenerators/Traits/DtoHelperTrait.php
new file mode 100644
index 0000000..c9dca8e
--- /dev/null
+++ b/generator/TestGenerators/Traits/DtoHelperTrait.php
@@ -0,0 +1,34 @@
+collection) {
+ $resourceName = NameHelper::resourceClassName($endpoint->collection);
+
+ // Use Laravel's Str::singular() for correct singular form
+ return Str::singular($resourceName);
+ }
+
+ // Fallback: try to parse from endpoint name
+ $name = $endpoint->name ?: NameHelper::pathBasedName($endpoint);
+ // Remove method prefix (get, post, patch)
+ $name = preg_replace('/^(get|post|patch)/i', '', $name);
+
+ // Use Laravel's Str::singular() for correct singular form
+ return Str::singular(NameHelper::resourceClassName($name));
+ }
+}
diff --git a/generator/TestGenerators/Traits/MockJsonDataTrait.php b/generator/TestGenerators/Traits/MockJsonDataTrait.php
new file mode 100644
index 0000000..41ac9f3
--- /dev/null
+++ b/generator/TestGenerators/Traits/MockJsonDataTrait.php
@@ -0,0 +1,37 @@
+ $value) {
+ $keyStr = is_string($key) ? "'$key'" : $key;
+
+ if (is_array($value)) {
+ $lines[] = "$keyStr => ".$this->formatArrayAsPhp($value).',';
+ } elseif (is_string($value)) {
+ $escapedValue = addslashes($value);
+ $lines[] = "$keyStr => '$escapedValue',";
+ } elseif (is_bool($value)) {
+ $lines[] = "$keyStr => ".($value ? 'true' : 'false').',';
+ } elseif (is_null($value)) {
+ $lines[] = "$keyStr => null,";
+ } else {
+ $lines[] = "$keyStr => $value,";
+ }
+ }
+
+ if (empty($lines)) {
+ return '[]';
+ }
+
+ return "[\n".implode("\n", $lines)."\n]";
+ }
+}
diff --git a/generator/TestGenerators/Traits/ResourceTypeExtractorTrait.php b/generator/TestGenerators/Traits/ResourceTypeExtractorTrait.php
new file mode 100644
index 0000000..ba83a10
--- /dev/null
+++ b/generator/TestGenerators/Traits/ResourceTypeExtractorTrait.php
@@ -0,0 +1,43 @@
+collection) {
+ $name = $endpoint->collection;
+ } else {
+ // Fallback: parse from endpoint path
+ $path = $endpoint->path;
+ // Extract first path segment (e.g., /budgets -> budgets)
+ preg_match('#^/([^/]+)#', $path, $matches);
+
+ if (count($matches) >= 2) {
+ throw new \RuntimeException('Resource type for "'.$path.'" does not exist');
+ }
+
+ $name = $matches[1];
+ }
+
+ $camelName = NameHelper::safeVariableName($name);
+
+ return Str::plural($camelName);
+ }
+}
diff --git a/generator/TestGenerators/Traits/TestDataGeneratorTrait.php b/generator/TestGenerators/Traits/TestDataGeneratorTrait.php
new file mode 100644
index 0000000..74a420e
--- /dev/null
+++ b/generator/TestGenerators/Traits/TestDataGeneratorTrait.php
@@ -0,0 +1,172 @@
+extractTypeInfo($typeInfo);
+
+ // Use example if available
+ if ($example !== null) {
+ return $example;
+ }
+
+ // DateTime fields (by format or name)
+ if ($format === 'date-time' || str_contains($propertyName, 'At') || str_contains($propertyName, 'Date')) {
+ return '2025-01-15T10:30:00Z';
+ }
+
+ // ID fields
+ if (str_ends_with($propertyName, 'Id')) {
+ return Str::snake($propertyName).'-123';
+ }
+
+ // Email fields
+ if (str_contains($propertyName, 'email') || str_contains($propertyName, 'Email')) {
+ return 'test@example.com';
+ }
+
+ // Boolean fields (by type or name prefix)
+ if ($type === 'boolean' || $type === 'bool' || str_starts_with($propertyName, 'is') || str_starts_with($propertyName, 'has')) {
+ return true;
+ }
+
+ // Numeric fields
+ if ($type === 'integer' || $type === 'int') {
+ return 42;
+ }
+
+ if ($type === 'number' || $type === 'float') {
+ return 3.14;
+ }
+
+ // Array/Object fields
+ if ($type === 'array' || $type === 'object') {
+ return [];
+ }
+
+ // Common string property names
+ if ($type === 'string' || $type === null) {
+ if ($propertyName === 'title') {
+ return 'test title';
+ }
+ if ($propertyName === 'description') {
+ return 'test description';
+ }
+ if ($propertyName === 'name') {
+ return 'test name';
+ }
+
+ return 'test value';
+ }
+
+ // This should never be reached with the current OpenAPI spec
+ throw new \RuntimeException("Unexpected type '{$type}' for property '{$propertyName}'");
+ }
+
+ /**
+ * Format a value as PHP code string for test generation
+ *
+ * @param mixed $value The value to format
+ * @return string PHP code representation
+ */
+ protected function formatAsCode(mixed $value): string
+ {
+ if (is_string($value)) {
+ $escapedValue = addslashes($value);
+
+ return "'{$escapedValue}'";
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+
+ if (is_int($value) || is_float($value)) {
+ return (string) $value;
+ }
+
+ if (is_array($value)) {
+ return '[]';
+ }
+
+ if (is_null($value)) {
+ return 'null';
+ }
+
+ // Fallback
+ return "'{$value}'";
+ }
+
+ /**
+ * Format a value for use in assertions (alias for formatAsCode for backward compatibility)
+ */
+ protected function formatValueForAssertion(string $value): string
+ {
+ // If value is already quoted or a keyword, return as-is
+ if (
+ (str_starts_with($value, "'") && str_ends_with($value, "'")) ||
+ $value === 'true' ||
+ $value === 'false' ||
+ is_numeric($value)
+ ) {
+ return $value;
+ }
+
+ // Wrap in quotes
+ return "'{$value}'";
+ }
+
+ /**
+ * Extract type, format, and example from various input formats
+ *
+ * @return array{0: ?string, 1: ?string, 2: mixed} [type, format, example]
+ */
+ private function extractTypeInfo(Schema|array|string|null $typeInfo): array
+ {
+ if ($typeInfo instanceof Schema) {
+ return [
+ $typeInfo->type ?? null,
+ $typeInfo->format ?? null,
+ $typeInfo->example ?? null,
+ ];
+ }
+
+ if (is_array($typeInfo)) {
+ return [
+ $typeInfo['type'] ?? null,
+ $typeInfo['format'] ?? null,
+ $typeInfo['example'] ?? null,
+ ];
+ }
+
+ if (is_string($typeInfo)) {
+ // Normalize type name (remove nullable prefix)
+ $normalizedType = ltrim($typeInfo, '?');
+
+ // Check for DateTime type hints
+ if (str_contains($normalizedType, 'Carbon') || str_contains($normalizedType, 'DateTime')) {
+ return ['string', 'date-time', null];
+ }
+
+ return [$normalizedType, null, null];
+ }
+
+ return [null, null, null];
+ }
+}
diff --git a/generator/TestGenerators/stubs/pest-collection-request-test-func.stub b/generator/TestGenerators/stubs/pest-collection-request-test-func.stub
new file mode 100644
index 0000000..1d260a4
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-collection-request-test-func.stub
@@ -0,0 +1,20 @@
+it('{{ testDescription }}', function () {
+ Saloon::fake([
+ {{ requestClass }}::class => MockResponse::fixture('{{ fixtureName }}'),
+ ]);
+
+ $request = (new {{ requestClass }}({{ nonFilterParams }}))
+ {{ filterChain }};
+
+ $response = $this->{{ clientName }}->send($request);
+
+ Saloon::assertSent({{ requestClass }}::class);
+ {{ filterAssertionBlock }}
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ {{ dtoAssertions }};
+});
diff --git a/generator/TestGenerators/stubs/pest-delete-request-test-func.stub b/generator/TestGenerators/stubs/pest-delete-request-test-func.stub
new file mode 100644
index 0000000..30f5041
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-delete-request-test-func.stub
@@ -0,0 +1,12 @@
+it('{{ testDescription }}', function () {
+ Saloon::fake([
+ {{ requestClass }}::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new {{ requestClass }}({{ methodArguments }});
+ $response = $this->{{ clientName }}->send($request);
+
+ Saloon::assertSent({{ requestClass }}::class);
+
+ expect($response->status())->toBe(200);
+});
diff --git a/generator/TestGenerators/stubs/pest-filter-assertion-block.stub b/generator/TestGenerators/stubs/pest-filter-assertion-block.stub
new file mode 100644
index 0000000..c28e09e
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-filter-assertion-block.stub
@@ -0,0 +1,9 @@
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ {{ filterAssertions }}
+
+ return true;
+ });
diff --git a/generator/TestGenerators/stubs/pest-mutation-test-func.stub b/generator/TestGenerators/stubs/pest-mutation-test-func.stub
new file mode 100644
index 0000000..a088910
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-mutation-test-func.stub
@@ -0,0 +1,15 @@
+it('{{ testDescription }}', function () {
+ $mockClient = Saloon::fake([
+ {{ requestClass }}::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ {{ dtoInstantiation }}
+
+ $request = new {{ requestClass }}({{ mutationMethodArguments }});
+ $this->{{ clientName }}->send($request);
+
+ Saloon::assertSent({{ requestClass }}::class);
+
+{{ bodyValidation }}
+});
diff --git a/generator/TestGenerators/stubs/pest-resource-test.stub b/generator/TestGenerators/stubs/pest-resource-test.stub
new file mode 100644
index 0000000..0aecca3
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-resource-test.stub
@@ -0,0 +1,12 @@
+{{ clientName }} = new {{ namespace }}\{{ connectorName }}({{ connectorArgs }});
+});
diff --git a/generator/TestGenerators/stubs/pest-singular-get-request-test-func.stub b/generator/TestGenerators/stubs/pest-singular-get-request-test-func.stub
new file mode 100644
index 0000000..45ea19d
--- /dev/null
+++ b/generator/TestGenerators/stubs/pest-singular-get-request-test-func.stub
@@ -0,0 +1,17 @@
+it('{{ testDescription }}', function () {
+ Saloon::fake([
+ {{ requestClass }}::class => {{ mockResponse }},
+ ]);
+
+ $request = new {{ requestClass }}({{ methodArguments }});
+ $response = $this->{{ clientName }}->send($request);
+
+ Saloon::assertSent({{ requestClass }}::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+{{ dtoAssertions }};
+});
diff --git a/generator/generate.php b/generator/generate.php
new file mode 100644
index 0000000..e0f421b
--- /dev/null
+++ b/generator/generate.php
@@ -0,0 +1,146 @@
+ [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ],
+]));
+
+if (! $openApiJson) {
+ echo "❌ Failed to download OpenAPI specification\n";
+ exit(1);
+}
+
+file_put_contents(__DIR__.'/../openapi.json', $openApiJson);
+echo "✅ OpenAPI specification downloaded\n\n";
+
+// Clean up previously generated folders
+echo "🧹 Cleaning up previously generated files...\n";
+$foldersToClean = [
+ __DIR__.'/../src/Requests',
+ __DIR__.'/../src/Resource',
+ __DIR__.'/../src/Dto',
+ __DIR__.'/../tests/Requests',
+];
+
+foreach ($foldersToClean as $folder) {
+ if (is_dir($folder)) {
+ // Recursively delete directory
+ $files = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($folder, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($files as $fileinfo) {
+ $todo = ($fileinfo->isDir() ? 'rmdir' : 'unlink');
+ $todo($fileinfo->getRealPath());
+ }
+
+ rmdir($folder);
+ echo ' ✓ Removed '.basename($folder)."\n";
+ }
+}
+
+echo "✅ Cleanup completed\n\n";
+
+// Parse the specification
+echo "🔨 Parsing OpenAPI specification...\n";
+$specification = Factory::parse('openapi', __DIR__.'/../openapi.json');
+echo "✅ Specification parsed\n\n";
+
+// Create config
+$config = new Config(
+ connectorName: 'TimaticConnector',
+ namespace: 'Timatic\\SDK',
+ resourceNamespaceSuffix: 'Resource',
+ requestNamespaceSuffix: 'Requests',
+ dtoNamespaceSuffix: 'Dto',
+);
+
+// Generate SDK code with tests in a single run
+echo "🏗️ Generating SDK with JSON:API models and tests...\n";
+$generator = new CodeGenerator(
+ config: $config,
+ connectorGenerator: new JsonApiConnectorGenerator($config),
+ dtoGenerator: new JsonApiDtoGenerator($config),
+ requestGenerator: new JsonApiRequestGenerator($config),
+ resourceGenerator: new JsonApiResourceGenerator($config),
+ postProcessors: [new JsonApiPestTestGenerator] // Generate tests in same run
+);
+
+$result = $generator->run($specification);
+
+// Output directory
+$outputDir = __DIR__.'/../src';
+
+// Helper function to write files
+function writeFile($file, $outputDir, $namespace)
+{
+ $relativePath = str_replace($namespace, '', array_values($file->getNamespaces())[0]->getName());
+ $className = array_values($file->getClasses())[0]->getName();
+ $filePath = $outputDir.str_replace('\\', '/', $relativePath).'/'.$className.'.php';
+
+ // Create directory if it doesn't exist
+ $dir = dirname($filePath);
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ file_put_contents($filePath, (string) $file);
+
+ return $filePath;
+}
+
+// Write requests
+echo "\n📝 Requests:\n";
+foreach ($result->requestClasses as $requestClass) {
+ $path = writeFile($requestClass, $outputDir, $config->namespace);
+ echo ' ✓ '.basename(dirname($path)).'/'.basename($path)."\n";
+}
+
+// Write DTOs (now Models!)
+echo "\n🎯 Models:\n";
+foreach ($result->dtoClasses as $dtoClass) {
+ $path = writeFile($dtoClass, $outputDir, $config->namespace);
+ echo ' ✓ '.basename($path)."\n";
+}
+
+// Write test files
+if ($result->additionalFiles && is_array($result->additionalFiles)) {
+ echo "\n🧪 Tests:\n";
+ foreach ($result->additionalFiles as $file) {
+ if ($file instanceof \Crescat\SaloonSdkGenerator\Data\TaggedOutputFile) {
+ $testPath = __DIR__.'/../'.$file->path;
+
+ // Create directory if it doesn't exist
+ $dir = dirname($testPath);
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ file_put_contents($testPath, $file->file);
+ echo ' ✓ '.basename($testPath)."\n";
+ }
+ }
+}
+
+// Dump autoload to make new classes available
+echo "\n";
+passthru('composer dump-autoload --quiet');
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..fe0605d
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,9 @@
+includes:
+ - '%currentWorkingDirectory%/vendor/larastan/larastan/extension.neon'
+
+parameters:
+ level: 5
+ paths:
+ - src
+ excludePaths:
+ - vendor
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..0502b72
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ ./tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/Concerns/HasAttributes.php b/src/Concerns/HasAttributes.php
new file mode 100644
index 0000000..858eb2b
--- /dev/null
+++ b/src/Concerns/HasAttributes.php
@@ -0,0 +1,66 @@
+
+ */
+ public function attributes(): array
+ {
+ $reflectionClass = new ReflectionClass($this);
+ $properties = $reflectionClass->getProperties();
+ $attributes = [];
+
+ foreach ($properties as $property) {
+ $propertyAttributes = $property->getAttributes(Property::class);
+
+ if (count($propertyAttributes) > 0) {
+ $propertyName = $property->getName();
+
+ // Skip if property is not initialized
+ if (! $property->isInitialized($this)) {
+ continue;
+ }
+
+ $value = $property->getValue($this);
+
+ /** @var Property $attr */
+ $attr = $propertyAttributes[0]->newInstance();
+
+ // Skip read-only properties when serializing (like id)
+ if ($attr->isReadOnly) {
+ continue;
+ }
+
+ $attributes[$propertyName] = $value;
+ }
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Get a specific attribute value
+ */
+ public function getAttribute(string $key): mixed
+ {
+ return $this->$key ?? null;
+ }
+
+ /**
+ * Set a specific attribute value
+ */
+ public function setAttribute(string $key, mixed $value): void
+ {
+ $this->$key = $value;
+ }
+}
diff --git a/src/Concerns/HasFilters.php b/src/Concerns/HasFilters.php
new file mode 100644
index 0000000..ffcca44
--- /dev/null
+++ b/src/Concerns/HasFilters.php
@@ -0,0 +1,23 @@
+value.']';
+ }
+
+ $this->query()->add($key, $value);
+
+ return $this;
+ }
+}
diff --git a/src/Dto/Approve.php b/src/Dto/Approve.php
new file mode 100644
index 0000000..10ba1fe
--- /dev/null
+++ b/src/Dto/Approve.php
@@ -0,0 +1,46 @@
+ $model
+ */
+ public function __construct(public string $model, public RelationType $type) {}
+}
diff --git a/src/Hydration/Facades/Hydrator.php b/src/Hydration/Facades/Hydrator.php
new file mode 100644
index 0000000..927411d
--- /dev/null
+++ b/src/Hydration/Facades/Hydrator.php
@@ -0,0 +1,21 @@
+ hydrateCollection(string $model, array $data, array|null $included = null)
+ * @method static Model hydrate(string $model, array $item, array|null $included = null)
+ */
+class Hydrator extends Facade
+{
+ protected static function getFacadeAccessor()
+ {
+ return \Timatic\SDK\Hydration\Hydrator::class;
+ }
+}
diff --git a/src/Hydration/Hydrator.php b/src/Hydration/Hydrator.php
new file mode 100644
index 0000000..35e33d1
--- /dev/null
+++ b/src/Hydration/Hydrator.php
@@ -0,0 +1,187 @@
+|Model $model
+ * @param array $item
+ * @param array|null $included
+ *
+ * @throws ReflectionException
+ */
+ public function hydrate(string|Model $model, array $item, ?array $included = null): Model
+ {
+ if (is_null($included)) {
+ $included = [];
+ }
+
+ $included = Arr::keyBy($included, fn ($includedItem) => $includedItem['id'].'-'.$includedItem['type']);
+
+ if (is_string($model)) {
+ $model = $this->getModelFromClassName($model);
+ }
+
+ $reflectionClass = new ReflectionClass($model);
+
+ $fillable = Arr::pluck($this->filterProperties($reflectionClass, Property::class), 'name');
+
+ $hydrator = function (Model $model, string $property, mixed $value) use ($fillable, $reflectionClass) {
+ if (in_array($property, $fillable)) {
+ $type = $reflectionClass->getProperty($property)->getType();
+
+ $propertyReflectionAttributes = $reflectionClass->getProperty($property)->getAttributes(Property::class);
+ Assert::count($propertyReflectionAttributes, 1);
+
+ $propertyArguments = $propertyReflectionAttributes[0]->getArguments();
+ if (isset($propertyArguments['hydrator'])) {
+ Assert::isCallable($propertyArguments['hydrator']);
+
+ $value = $propertyArguments['hydrator']($value);
+ }
+
+ $dateTimeAttributes = $reflectionClass->getProperty($property)->getAttributes(DateTime::class);
+ if ($dateTimeAttributes !== []) {
+
+ if ($value === null && $reflectionClass->getProperty($property)->getType()?->allowsNull()) {
+ $value = null;
+ } else {
+ $value = Carbon::parse($value);
+ }
+ }
+
+ Assert::isInstanceOf($type, ReflectionNamedType::class);
+
+ $model->$property = $value;
+ }
+ };
+
+ $hydrator($model, 'id', $item['id']);
+
+ foreach ($item['attributes'] as $attribute => $value) {
+ $hydrator($model, $attribute, $value);
+ }
+
+ $this->hydrateRelations($reflectionClass, $item, $included, $model);
+
+ return $model;
+ }
+
+ /**
+ * @param class-string $model
+ * @param array $data
+ * @param array|null $included
+ * @return Collection
+ */
+ public function hydrateCollection(string $model, array $data, ?array $included = null): Collection
+ {
+ $collection = new Collection;
+
+ foreach ($data as $item) {
+ $collection->add($this->hydrate($model, $item, $included));
+ }
+
+ return $collection;
+ }
+
+ /**
+ * @param ReflectionClass $reflectionClass
+ * @return array
+ */
+ private function filterProperties(ReflectionClass $reflectionClass, string ...$attributes): array
+ {
+ return array_values(array_filter(
+ $reflectionClass->getProperties(),
+ function (ReflectionProperty $value) use ($attributes) {
+ $hasAttributes = true;
+
+ foreach ($attributes as $attribute) {
+ $hasAttributes = (bool) count($value->getAttributes($attribute));
+ }
+
+ return $hasAttributes;
+ }
+ ));
+ }
+
+ /**
+ * @param ReflectionClass $reflectionClass
+ * @param array $item
+ * @param array $included
+ */
+ protected function hydrateRelations(ReflectionClass $reflectionClass, array $item, array $included, Model $model): void
+ {
+ $relationProperties = Arr::keyBy($this->filterProperties($reflectionClass, Relationship::class), 'name');
+
+ if (! array_key_exists('relationships', $item)) {
+ return;
+ }
+
+ foreach ($item['relationships'] as $relationshipName => $relationship) {
+ if (
+ ! array_key_exists($relationshipName, $relationProperties)
+ || ! array_key_exists('data', $relationship)
+ || $relationship['data'] === null
+ ) {
+ continue;
+ }
+
+ /** @var Relationship $relationAttribute */
+ $relationAttribute = $relationProperties[$relationshipName]
+ ->getAttributes(Relationship::class)[0]->newInstance();
+
+ $relationModel = $relationAttribute->model;
+
+ if ($relationAttribute->type === RelationType::Many) {
+ $hydratedRelation = new Collection;
+
+ foreach ($relationship['data'] as $relationItem) {
+ $includedItem = $included[$relationItem['id'].'-'.$relationItem['type']] ?? null;
+
+ if (! is_null($includedItem)) {
+ $hydratedRelation->push(
+ $this->hydrate($relationModel, $includedItem, $included)
+ );
+ }
+ }
+
+ $model->{$relationshipName} = $hydratedRelation;
+ } elseif ($relationAttribute->type === RelationType::One) {
+ $relationItem = $relationship['data'];
+ $includedItem = $included[$relationItem['id'].'-'.$relationItem['type']] ?? null;
+
+ $model->{$relationshipName} = $this->hydrate($relationModel, $includedItem, $included);
+ }
+ }
+ }
+
+ /**
+ * @param class-string $model
+ */
+ private function getModelFromClassName(string $model): Model
+ {
+ $reflectionClass = new ReflectionClass($model);
+ if ($reflectionClass->getConstructor()?->getNumberOfRequiredParameters() > 0) {
+ return $reflectionClass->newInstanceWithoutConstructor();
+ }
+
+ return new $model;
+ }
+}
diff --git a/src/Hydration/Model.php b/src/Hydration/Model.php
new file mode 100644
index 0000000..0403d79
--- /dev/null
+++ b/src/Hydration/Model.php
@@ -0,0 +1,83 @@
+ $attributes
+ */
+ public function __construct(array $attributes = [])
+ {
+ $this->fill($attributes);
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public function fill(array $attributes): void
+ {
+ $propertyNames = $this->propertyNames();
+
+ foreach ($attributes as $key => $value) {
+ if (in_array($key, $propertyNames)) {
+ $this->$key = $value;
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function propertyNames(): array
+ {
+ $reflectionClass = new ReflectionClass($this);
+ $properties = $reflectionClass->getProperties();
+ $propertyNames = [];
+
+ foreach ($properties as $property) {
+ $attributes = $property->getAttributes(Property::class);
+ if (count($attributes) > 0) {
+ $propertyNames[] = $property->getName();
+ }
+ }
+
+ return $propertyNames;
+ }
+
+ public function type(): string
+ {
+ return $this->type ?? Str::of(
+ (new ReflectionClass($this))->getShortName()
+ )->camel()->plural()->toString();
+ }
+
+ /**
+ * Convert Model to JSON:API format
+ *
+ * @return array
+ */
+ public function toJsonApi(): array
+ {
+ return [
+ 'data' => [
+ 'type' => $this->type(),
+ 'attributes' => $this->attributes(),
+ ],
+ ];
+ }
+}
diff --git a/src/Hydration/ModelInterface.php b/src/Hydration/ModelInterface.php
new file mode 100644
index 0000000..a57f2aa
--- /dev/null
+++ b/src/Hydration/ModelInterface.php
@@ -0,0 +1,14 @@
+json('links.next') === null;
+ }
+
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ // Return the 'data' array from JSON:API response
+ return $response->json('data', []);
+ }
+
+ protected function applyPagination(Request $request): Request
+ {
+ $request->query()->add('page[number]', $this->page);
+
+ if (isset($this->perPageLimit)) {
+ $request->query()->add('page[size]', $this->perPageLimit);
+ }
+
+ return $request;
+ }
+}
diff --git a/src/Providers/TimaticServiceProvider.php b/src/Providers/TimaticServiceProvider.php
new file mode 100644
index 0000000..5394521
--- /dev/null
+++ b/src/Providers/TimaticServiceProvider.php
@@ -0,0 +1,40 @@
+mergeConfigFrom(
+ __DIR__.'/../../config/timatic.php',
+ 'timatic'
+ );
+
+ // Register TimaticConnector as singleton
+ $this->app->singleton(TimaticConnector::class);
+
+ // Register alias
+ $this->app->alias(TimaticConnector::class, 'timatic');
+ }
+
+ /**
+ * Bootstrap services.
+ */
+ public function boot(): void
+ {
+ // Publish config
+ $this->publishes([
+ __DIR__.'/../../config/timatic.php' => config_path('timatic.php'),
+ ], 'timatic-config');
+ }
+}
diff --git a/src/Requests/Approve/PostOvertimeApproveRequest.php b/src/Requests/Approve/PostOvertimeApproveRequest.php
new file mode 100644
index 0000000..5d6457f
--- /dev/null
+++ b/src/Requests/Approve/PostOvertimeApproveRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/overtimes/{$this->overtimeId}/approve";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $overtimeId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Budget/DeleteBudgetRequest.php b/src/Requests/Budget/DeleteBudgetRequest.php
new file mode 100644
index 0000000..b107701
--- /dev/null
+++ b/src/Requests/Budget/DeleteBudgetRequest.php
@@ -0,0 +1,23 @@
+budgetId}";
+ }
+
+ public function __construct(
+ protected string $budgetId,
+ ) {}
+}
diff --git a/src/Requests/Budget/GetBudgetRequest.php b/src/Requests/Budget/GetBudgetRequest.php
new file mode 100644
index 0000000..920e0a2
--- /dev/null
+++ b/src/Requests/Budget/GetBudgetRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/budgets/{$this->budgetId}";
+ }
+
+ public function __construct(
+ protected string $budgetId,
+ ) {}
+}
diff --git a/src/Requests/Budget/GetBudgetsRequest.php b/src/Requests/Budget/GetBudgetsRequest.php
new file mode 100644
index 0000000..e126f42
--- /dev/null
+++ b/src/Requests/Budget/GetBudgetsRequest.php
@@ -0,0 +1,46 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/budgets';
+ }
+
+ public function __construct(
+ protected ?string $include = null,
+ ) {}
+
+ protected function defaultQuery(): array
+ {
+ return array_filter(['include' => $this->include]);
+ }
+}
diff --git a/src/Requests/Budget/PatchBudgetRequest.php b/src/Requests/Budget/PatchBudgetRequest.php
new file mode 100644
index 0000000..f82297f
--- /dev/null
+++ b/src/Requests/Budget/PatchBudgetRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/budgets/{$this->budgetId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $budgetId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Budget/PostBudgetsRequest.php b/src/Requests/Budget/PostBudgetsRequest.php
new file mode 100644
index 0000000..c030729
--- /dev/null
+++ b/src/Requests/Budget/PostBudgetsRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/budgets';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsRequest.php b/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsRequest.php
new file mode 100644
index 0000000..9c61cff
--- /dev/null
+++ b/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/budget-time-spent-totals';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/BudgetType/GetBudgetTypesRequest.php b/src/Requests/BudgetType/GetBudgetTypesRequest.php
new file mode 100644
index 0000000..0197a88
--- /dev/null
+++ b/src/Requests/BudgetType/GetBudgetTypesRequest.php
@@ -0,0 +1,36 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/budget-types';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Change/GetChangeRequest.php b/src/Requests/Change/GetChangeRequest.php
new file mode 100644
index 0000000..9cdb833
--- /dev/null
+++ b/src/Requests/Change/GetChangeRequest.php
@@ -0,0 +1,23 @@
+changeId}";
+ }
+
+ public function __construct(
+ protected string $changeId,
+ ) {}
+}
diff --git a/src/Requests/Change/GetChangesRequest.php b/src/Requests/Change/GetChangesRequest.php
new file mode 100644
index 0000000..9a26355
--- /dev/null
+++ b/src/Requests/Change/GetChangesRequest.php
@@ -0,0 +1,22 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/corrections/{$this->correctionId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $correctionId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Correction/PostCorrectionsRequest.php b/src/Requests/Correction/PostCorrectionsRequest.php
new file mode 100644
index 0000000..a328c9e
--- /dev/null
+++ b/src/Requests/Correction/PostCorrectionsRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/corrections';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Customer/DeleteCustomerRequest.php b/src/Requests/Customer/DeleteCustomerRequest.php
new file mode 100644
index 0000000..4d3677a
--- /dev/null
+++ b/src/Requests/Customer/DeleteCustomerRequest.php
@@ -0,0 +1,23 @@
+customerId}";
+ }
+
+ public function __construct(
+ protected string $customerId,
+ ) {}
+}
diff --git a/src/Requests/Customer/GetCustomerRequest.php b/src/Requests/Customer/GetCustomerRequest.php
new file mode 100644
index 0000000..08f46a0
--- /dev/null
+++ b/src/Requests/Customer/GetCustomerRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/customers/{$this->customerId}";
+ }
+
+ public function __construct(
+ protected string $customerId,
+ ) {}
+}
diff --git a/src/Requests/Customer/GetCustomersRequest.php b/src/Requests/Customer/GetCustomersRequest.php
new file mode 100644
index 0000000..5ef2f5a
--- /dev/null
+++ b/src/Requests/Customer/GetCustomersRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/customers';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Customer/PatchCustomerRequest.php b/src/Requests/Customer/PatchCustomerRequest.php
new file mode 100644
index 0000000..0822d8c
--- /dev/null
+++ b/src/Requests/Customer/PatchCustomerRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/customers/{$this->customerId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $customerId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Customer/PostCustomersRequest.php b/src/Requests/Customer/PostCustomersRequest.php
new file mode 100644
index 0000000..3a5fa72
--- /dev/null
+++ b/src/Requests/Customer/PostCustomersRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/customers';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/DailyProgress/GetDailyProgressesRequest.php b/src/Requests/DailyProgress/GetDailyProgressesRequest.php
new file mode 100644
index 0000000..224aa2f
--- /dev/null
+++ b/src/Requests/DailyProgress/GetDailyProgressesRequest.php
@@ -0,0 +1,36 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/daily-progress';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php b/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php
new file mode 100644
index 0000000..1cb5221
--- /dev/null
+++ b/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php
@@ -0,0 +1,29 @@
+budgetId}/entries-export";
+ }
+
+ public function __construct(
+ protected string $budgetId,
+ protected ?string $include = null,
+ ) {}
+
+ protected function defaultQuery(): array
+ {
+ return array_filter(['include' => $this->include]);
+ }
+}
diff --git a/src/Requests/Entry/DeleteEntryRequest.php b/src/Requests/Entry/DeleteEntryRequest.php
new file mode 100644
index 0000000..96491dd
--- /dev/null
+++ b/src/Requests/Entry/DeleteEntryRequest.php
@@ -0,0 +1,23 @@
+entryId}";
+ }
+
+ public function __construct(
+ protected string $entryId,
+ ) {}
+}
diff --git a/src/Requests/Entry/GetEntriesRequest.php b/src/Requests/Entry/GetEntriesRequest.php
new file mode 100644
index 0000000..8c4b3f6
--- /dev/null
+++ b/src/Requests/Entry/GetEntriesRequest.php
@@ -0,0 +1,46 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/entries';
+ }
+
+ public function __construct(
+ protected ?string $include = null,
+ ) {}
+
+ protected function defaultQuery(): array
+ {
+ return array_filter(['include' => $this->include]);
+ }
+}
diff --git a/src/Requests/Entry/GetEntryRequest.php b/src/Requests/Entry/GetEntryRequest.php
new file mode 100644
index 0000000..a6919b4
--- /dev/null
+++ b/src/Requests/Entry/GetEntryRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/entries/{$this->entryId}";
+ }
+
+ public function __construct(
+ protected string $entryId,
+ ) {}
+}
diff --git a/src/Requests/Entry/PatchEntryRequest.php b/src/Requests/Entry/PatchEntryRequest.php
new file mode 100644
index 0000000..8a38c6d
--- /dev/null
+++ b/src/Requests/Entry/PatchEntryRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/entries/{$this->entryId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $entryId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Entry/PostEntriesRequest.php b/src/Requests/Entry/PostEntriesRequest.php
new file mode 100644
index 0000000..b5ff24f
--- /dev/null
+++ b/src/Requests/Entry/PostEntriesRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/entries';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/EntrySuggestion/DeleteEntrySuggestionRequest.php b/src/Requests/EntrySuggestion/DeleteEntrySuggestionRequest.php
new file mode 100644
index 0000000..96e86e5
--- /dev/null
+++ b/src/Requests/EntrySuggestion/DeleteEntrySuggestionRequest.php
@@ -0,0 +1,23 @@
+entrySuggestionId}";
+ }
+
+ public function __construct(
+ protected string $entrySuggestionId,
+ ) {}
+}
diff --git a/src/Requests/EntrySuggestion/GetEntrySuggestionRequest.php b/src/Requests/EntrySuggestion/GetEntrySuggestionRequest.php
new file mode 100644
index 0000000..4494213
--- /dev/null
+++ b/src/Requests/EntrySuggestion/GetEntrySuggestionRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/entry-suggestions/{$this->entrySuggestionId}";
+ }
+
+ public function __construct(
+ protected string $entrySuggestionId,
+ ) {}
+}
diff --git a/src/Requests/EntrySuggestion/GetEntrySuggestionsRequest.php b/src/Requests/EntrySuggestion/GetEntrySuggestionsRequest.php
new file mode 100644
index 0000000..5e1239a
--- /dev/null
+++ b/src/Requests/EntrySuggestion/GetEntrySuggestionsRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/entry-suggestions';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Event/PostEventsRequest.php b/src/Requests/Event/PostEventsRequest.php
new file mode 100644
index 0000000..b46bbd0
--- /dev/null
+++ b/src/Requests/Event/PostEventsRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/events';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/ExportMail/GetBudgetsExportMailsRequest.php b/src/Requests/ExportMail/GetBudgetsExportMailsRequest.php
new file mode 100644
index 0000000..6aa927b
--- /dev/null
+++ b/src/Requests/ExportMail/GetBudgetsExportMailsRequest.php
@@ -0,0 +1,36 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/budgets/export-mail';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Incident/GetIncidentRequest.php b/src/Requests/Incident/GetIncidentRequest.php
new file mode 100644
index 0000000..41c0265
--- /dev/null
+++ b/src/Requests/Incident/GetIncidentRequest.php
@@ -0,0 +1,23 @@
+incidentId}";
+ }
+
+ public function __construct(
+ protected string $incidentId,
+ ) {}
+}
diff --git a/src/Requests/Incident/GetIncidentsRequest.php b/src/Requests/Incident/GetIncidentsRequest.php
new file mode 100644
index 0000000..77770fa
--- /dev/null
+++ b/src/Requests/Incident/GetIncidentsRequest.php
@@ -0,0 +1,22 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/overtimes/{$this->overtimeId}/mark-as-exported";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $overtimeId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/MarkAsInvoiced/PostEntryMarkAsInvoicedRequest.php b/src/Requests/MarkAsInvoiced/PostEntryMarkAsInvoicedRequest.php
new file mode 100644
index 0000000..fafe2ff
--- /dev/null
+++ b/src/Requests/MarkAsInvoiced/PostEntryMarkAsInvoicedRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/entries/{$this->entryId}/mark-as-invoiced";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $entryId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Me/GetMesRequest.php b/src/Requests/Me/GetMesRequest.php
new file mode 100644
index 0000000..107fd45
--- /dev/null
+++ b/src/Requests/Me/GetMesRequest.php
@@ -0,0 +1,22 @@
+incidentId}";
+ }
+
+ public function __construct(
+ protected string $incidentId,
+ ) {}
+}
diff --git a/src/Requests/Overtime/GetOvertimesRequest.php b/src/Requests/Overtime/GetOvertimesRequest.php
new file mode 100644
index 0000000..18541ad
--- /dev/null
+++ b/src/Requests/Overtime/GetOvertimesRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/overtimes';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Period/GetBudgetPeriodsRequest.php b/src/Requests/Period/GetBudgetPeriodsRequest.php
new file mode 100644
index 0000000..ddfa825
--- /dev/null
+++ b/src/Requests/Period/GetBudgetPeriodsRequest.php
@@ -0,0 +1,23 @@
+budgetId}/periods";
+ }
+
+ public function __construct(
+ protected string $budgetId,
+ ) {}
+}
diff --git a/src/Requests/Team/DeleteTeamRequest.php b/src/Requests/Team/DeleteTeamRequest.php
new file mode 100644
index 0000000..484a10d
--- /dev/null
+++ b/src/Requests/Team/DeleteTeamRequest.php
@@ -0,0 +1,23 @@
+teamId}";
+ }
+
+ public function __construct(
+ protected string $teamId,
+ ) {}
+}
diff --git a/src/Requests/Team/GetTeamRequest.php b/src/Requests/Team/GetTeamRequest.php
new file mode 100644
index 0000000..291d57d
--- /dev/null
+++ b/src/Requests/Team/GetTeamRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/teams/{$this->teamId}";
+ }
+
+ public function __construct(
+ protected string $teamId,
+ ) {}
+}
diff --git a/src/Requests/Team/GetTeamsRequest.php b/src/Requests/Team/GetTeamsRequest.php
new file mode 100644
index 0000000..696b165
--- /dev/null
+++ b/src/Requests/Team/GetTeamsRequest.php
@@ -0,0 +1,36 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/teams';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/Team/PatchTeamRequest.php b/src/Requests/Team/PatchTeamRequest.php
new file mode 100644
index 0000000..51842fe
--- /dev/null
+++ b/src/Requests/Team/PatchTeamRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/teams/{$this->teamId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $teamId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/Team/PostTeamsRequest.php b/src/Requests/Team/PostTeamsRequest.php
new file mode 100644
index 0000000..f031ae8
--- /dev/null
+++ b/src/Requests/Team/PostTeamsRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/teams';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/TimeSpentTotal/GetTimeSpentTotalsRequest.php b/src/Requests/TimeSpentTotal/GetTimeSpentTotalsRequest.php
new file mode 100644
index 0000000..4e8624c
--- /dev/null
+++ b/src/Requests/TimeSpentTotal/GetTimeSpentTotalsRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/time-spent-totals';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/User/DeleteUserRequest.php b/src/Requests/User/DeleteUserRequest.php
new file mode 100644
index 0000000..4edadca
--- /dev/null
+++ b/src/Requests/User/DeleteUserRequest.php
@@ -0,0 +1,23 @@
+userId}";
+ }
+
+ public function __construct(
+ protected string $userId,
+ ) {}
+}
diff --git a/src/Requests/User/GetUserRequest.php b/src/Requests/User/GetUserRequest.php
new file mode 100644
index 0000000..b91dc81
--- /dev/null
+++ b/src/Requests/User/GetUserRequest.php
@@ -0,0 +1,37 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/users/{$this->userId}";
+ }
+
+ public function __construct(
+ protected string $userId,
+ ) {}
+}
diff --git a/src/Requests/User/GetUsersRequest.php b/src/Requests/User/GetUsersRequest.php
new file mode 100644
index 0000000..33c1572
--- /dev/null
+++ b/src/Requests/User/GetUsersRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Requests/User/PatchUserRequest.php b/src/Requests/User/PatchUserRequest.php
new file mode 100644
index 0000000..6506298
--- /dev/null
+++ b/src/Requests/User/PatchUserRequest.php
@@ -0,0 +1,51 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return "/users/{$this->userId}";
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected string $userId,
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/User/PostUsersRequest.php b/src/Requests/User/PostUsersRequest.php
new file mode 100644
index 0000000..ae43f6b
--- /dev/null
+++ b/src/Requests/User/PostUsersRequest.php
@@ -0,0 +1,50 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * @param null|\Timatic\SDK\Hydration\Model|array|null $data Request data
+ */
+ public function __construct(
+ protected Model|array|null $data = null,
+ ) {}
+
+ protected function defaultBody(): array
+ {
+ return $this->data ? $this->data->toJsonApi() : [];
+ }
+}
diff --git a/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesRequest.php b/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesRequest.php
new file mode 100644
index 0000000..61c1239
--- /dev/null
+++ b/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesRequest.php
@@ -0,0 +1,39 @@
+model,
+ $response->json('data'),
+ $response->json('included')
+ );
+ }
+
+ public function resolveEndpoint(): string
+ {
+ return '/user-customer-hours-aggregates';
+ }
+
+ public function __construct() {}
+}
diff --git a/src/Responses/TimaticResponse.php b/src/Responses/TimaticResponse.php
new file mode 100644
index 0000000..1d26ae9
--- /dev/null
+++ b/src/Responses/TimaticResponse.php
@@ -0,0 +1,64 @@
+json('data');
+
+ if (is_array($data) && isset($data[0])) {
+ return $data[0];
+ }
+
+ return $data;
+ }
+
+ /**
+ * Check if response contains JSON:API errors
+ */
+ public function hasErrors(): bool
+ {
+ return $this->json('errors') !== null;
+ }
+
+ /**
+ * Get JSON:API errors
+ */
+ public function errors(): array
+ {
+ return $this->json('errors', []);
+ }
+
+ /**
+ * Get meta information from JSON:API response
+ */
+ public function meta(): array
+ {
+ return $this->json('meta', []);
+ }
+
+ /**
+ * Get links from JSON:API response
+ */
+ public function links(): array
+ {
+ return $this->json('links', []);
+ }
+
+ /**
+ * Get included resources from JSON:API response
+ */
+ public function included(): array
+ {
+ return $this->json('included', []);
+ }
+}
diff --git a/src/TimaticConnector.php b/src/TimaticConnector.php
new file mode 100644
index 0000000..f650392
--- /dev/null
+++ b/src/TimaticConnector.php
@@ -0,0 +1,46 @@
+ 'application/vnd.api+json',
+ 'Content-Type' => 'application/vnd.api+json',
+ ];
+
+ if ($token = config('timatic.api_token')) {
+ $headers['Authorization'] = "Bearer {$token}";
+ }
+
+ return $headers;
+ }
+
+ public function resolveResponseClass(): string
+ {
+ return TimaticResponse::class;
+ }
+
+ public function paginate(Request $request): JsonApiPaginator
+ {
+ return new JsonApiPaginator($this, $request);
+ }
+
+ public function __construct() {}
+
+ public function resolveBaseUrl(): string
+ {
+ return config('timatic.base_url');
+ }
+}
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..79a7abd
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,5 @@
+in(__DIR__);
diff --git a/tests/Requests/ApproveTest.php b/tests/Requests/ApproveTest.php
new file mode 100644
index 0000000..276517a
--- /dev/null
+++ b/tests/Requests/ApproveTest.php
@@ -0,0 +1,42 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the postOvertimeApprove method in the Approve resource', function () {
+ $mockClient = Saloon::fake([
+ PostOvertimeApproveRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Approve;
+ $dto->entryId = 'entry_id-123';
+ $dto->overtimeTypeId = 'overtime_type_id-123';
+ $dto->startedAt = \Carbon\Carbon::parse('2025-01-15T10:30:00Z');
+ $dto->endedAt = \Carbon\Carbon::parse('2025-01-15T10:30:00Z');
+
+ $request = new PostOvertimeApproveRequest(overtimeId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostOvertimeApproveRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('approves')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->entryId->toBe('entry_id-123')
+ ->overtimeTypeId->toBe('overtime_type_id-123')
+ ->startedAt->toEqual(new \Carbon\Carbon('2025-01-15T10:30:00Z'))
+ ->endedAt->toEqual(new \Carbon\Carbon('2025-01-15T10:30:00Z'))
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/BudgetTest.php b/tests/Requests/BudgetTest.php
new file mode 100644
index 0000000..898feca
--- /dev/null
+++ b/tests/Requests/BudgetTest.php
@@ -0,0 +1,237 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getBudgets method in the Budget resource', function () {
+ Saloon::fake([
+ GetBudgetsRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'budgets',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'budgetTypeId' => 'mock-id-123',
+ 'customerId' => 'mock-id-123',
+ 'showToCustomer' => true,
+ 'changeId' => 'mock-id-123',
+ 'contractId' => 'mock-id-123',
+ 'title' => 'Mock value',
+ 'description' => 'Mock value',
+ 'totalPrice' => 'Mock value',
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'initialMinutes' => 42,
+ 'isArchived' => true,
+ 'renewalFrequency' => 'Mock value',
+ 'supervisorUserId' => 'mock-id-123',
+ ],
+ ],
+ 1 => [
+ 'type' => 'budgets',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'budgetTypeId' => 'mock-id-123',
+ 'customerId' => 'mock-id-123',
+ 'showToCustomer' => true,
+ 'changeId' => 'mock-id-123',
+ 'contractId' => 'mock-id-123',
+ 'title' => 'Mock value',
+ 'description' => 'Mock value',
+ 'totalPrice' => 'Mock value',
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'initialMinutes' => 42,
+ 'isArchived' => true,
+ 'renewalFrequency' => 'Mock value',
+ 'supervisorUserId' => 'mock-id-123',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetBudgetsRequest(include: 'test string'))
+ ->filter('customerId', 'customer_id-123')
+ ->filter('budgetTypeId', 'budget_type_id-123')
+ ->filter('isArchived', true);
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetBudgetsRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[customerId]', 'customer_id-123');
+ expect($query)->toHaveKey('filter[budgetTypeId]', 'budget_type_id-123');
+ expect($query)->toHaveKey('filter[isArchived]', true);
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->budgetTypeId->toBe('mock-id-123')
+ ->customerId->toBe('mock-id-123')
+ ->showToCustomer->toBe(true)
+ ->changeId->toBe('mock-id-123')
+ ->contractId->toBe('mock-id-123')
+ ->title->toBe('Mock value')
+ ->description->toBe('Mock value')
+ ->totalPrice->toBe('Mock value')
+ ->startedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->endedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->initialMinutes->toBe(42)
+ ->isArchived->toBe(true)
+ ->renewalFrequency->toBe('Mock value')
+ ->supervisorUserId->toBe('mock-id-123');
+});
+
+it('calls the postBudgets method in the Budget resource', function () {
+ $mockClient = Saloon::fake([
+ PostBudgetsRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Budget;
+ $dto->budgetTypeId = 'budget_type_id-123';
+ $dto->customerId = 'customer_id-123';
+ $dto->showToCustomer = true;
+ $dto->changeId = 'change_id-123';
+
+ $request = new PostBudgetsRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostBudgetsRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('budgets')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->budgetTypeId->toBe('budget_type_id-123')
+ ->customerId->toBe('customer_id-123')
+ ->showToCustomer->toBe(true)
+ ->changeId->toBe('change_id-123')
+ );
+
+ return true;
+ });
+});
+
+it('calls the getBudget method in the Budget resource', function () {
+ Saloon::fake([
+ GetBudgetRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'budgets',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'budgetTypeId' => 'mock-id-123',
+ 'customerId' => 'mock-id-123',
+ 'showToCustomer' => true,
+ 'changeId' => 'mock-id-123',
+ 'contractId' => 'mock-id-123',
+ 'title' => 'Mock value',
+ 'description' => 'Mock value',
+ 'totalPrice' => 'Mock value',
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'initialMinutes' => 42,
+ 'isArchived' => true,
+ 'renewalFrequency' => 'Mock value',
+ 'supervisorUserId' => 'mock-id-123',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetBudgetRequest(
+ budgetId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetBudgetRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->budgetTypeId->toBe('mock-id-123')
+ ->customerId->toBe('mock-id-123')
+ ->showToCustomer->toBe(true)
+ ->changeId->toBe('mock-id-123')
+ ->contractId->toBe('mock-id-123')
+ ->title->toBe('Mock value')
+ ->description->toBe('Mock value')
+ ->totalPrice->toBe('Mock value')
+ ->startedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->endedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->initialMinutes->toBe(42)
+ ->isArchived->toBe(true)
+ ->renewalFrequency->toBe('Mock value')
+ ->supervisorUserId->toBe('mock-id-123');
+});
+
+it('calls the deleteBudget method in the Budget resource', function () {
+ Saloon::fake([
+ DeleteBudgetRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteBudgetRequest(
+ budgetId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteBudgetRequest::class);
+
+ expect($response->status())->toBe(200);
+});
+
+it('calls the patchBudget method in the Budget resource', function () {
+ $mockClient = Saloon::fake([
+ PatchBudgetRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Budget;
+ $dto->budgetTypeId = 'budget_type_id-123';
+ $dto->customerId = 'customer_id-123';
+ $dto->showToCustomer = true;
+ $dto->changeId = 'change_id-123';
+
+ $request = new PatchBudgetRequest(budgetId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchBudgetRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('budgets')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->budgetTypeId->toBe('budget_type_id-123')
+ ->customerId->toBe('customer_id-123')
+ ->showToCustomer->toBe(true)
+ ->changeId->toBe('change_id-123')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/BudgetTimeSpentTotalTest.php b/tests/Requests/BudgetTimeSpentTotalTest.php
new file mode 100644
index 0000000..86ec251
--- /dev/null
+++ b/tests/Requests/BudgetTimeSpentTotalTest.php
@@ -0,0 +1,69 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getBudgetTimeSpentTotals method in the BudgetTimeSpentTotal resource', function () {
+ Saloon::fake([
+ GetBudgetTimeSpentTotalsRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'budgetTimeSpentTotals',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'start' => '2025-11-22T10:40:04.065Z',
+ 'end' => '2025-11-22T10:40:04.065Z',
+ 'remainingMinutes' => 42,
+ 'periodUnit' => 'Mock value',
+ 'periodValue' => 42,
+ ],
+ ],
+ 1 => [
+ 'type' => 'budgetTimeSpentTotals',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'start' => '2025-11-22T10:40:04.065Z',
+ 'end' => '2025-11-22T10:40:04.065Z',
+ 'remainingMinutes' => 42,
+ 'periodUnit' => 'Mock value',
+ 'periodValue' => 42,
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetBudgetTimeSpentTotalsRequest)
+ ->filter('budgetId', 'budget_id-123');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetBudgetTimeSpentTotalsRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[budgetId]', 'budget_id-123');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->start->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->end->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->remainingMinutes->toBe(42)
+ ->periodUnit->toBe('Mock value')
+ ->periodValue->toBe(42);
+});
diff --git a/tests/Requests/BudgetTypeTest.php b/tests/Requests/BudgetTypeTest.php
new file mode 100644
index 0000000..f8a836a
--- /dev/null
+++ b/tests/Requests/BudgetTypeTest.php
@@ -0,0 +1,69 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getBudgetTypes method in the BudgetType resource', function () {
+ Saloon::fake([
+ GetBudgetTypesRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'budgetTypes',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'title' => 'Mock value',
+ 'isArchived' => true,
+ 'hasChangeTicket' => true,
+ 'renewalFrequencies' => 'Mock value',
+ 'hasSupervisor' => true,
+ 'hasContractId' => true,
+ 'hasTotalPrice' => true,
+ 'ticketIsRequired' => true,
+ 'defaultTitle' => 'Mock value',
+ ],
+ ],
+ 1 => [
+ 'type' => 'budgetTypes',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'title' => 'Mock value',
+ 'isArchived' => true,
+ 'hasChangeTicket' => true,
+ 'renewalFrequencies' => 'Mock value',
+ 'hasSupervisor' => true,
+ 'hasContractId' => true,
+ 'hasTotalPrice' => true,
+ 'ticketIsRequired' => true,
+ 'defaultTitle' => 'Mock value',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetBudgetTypesRequest);
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetBudgetTypesRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->title->toBe('Mock value')
+ ->isArchived->toBe(true)
+ ->hasChangeTicket->toBe(true)
+ ->renewalFrequencies->toBe('Mock value')
+ ->hasSupervisor->toBe(true)
+ ->hasContractId->toBe(true)
+ ->hasTotalPrice->toBe(true)
+ ->ticketIsRequired->toBe(true)
+ ->defaultTitle->toBe('Mock value');
+});
diff --git a/tests/Requests/CorrectionTest.php b/tests/Requests/CorrectionTest.php
new file mode 100644
index 0000000..5c76d63
--- /dev/null
+++ b/tests/Requests/CorrectionTest.php
@@ -0,0 +1,57 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the postCorrections method in the Correction resource', function () {
+ $mockClient = Saloon::fake([
+ PostCorrectionsRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Correction;
+ $dto->name = 'test value';
+
+ $request = new PostCorrectionsRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostCorrectionsRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('corrections');
+
+ return true;
+ });
+});
+
+it('calls the patchCorrection method in the Correction resource', function () {
+ $mockClient = Saloon::fake([
+ PatchCorrectionRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Correction;
+ $dto->name = 'test value';
+
+ $request = new PatchCorrectionRequest(correctionId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchCorrectionRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('corrections');
+
+ return true;
+ });
+});
diff --git a/tests/Requests/CustomerTest.php b/tests/Requests/CustomerTest.php
new file mode 100644
index 0000000..84803d3
--- /dev/null
+++ b/tests/Requests/CustomerTest.php
@@ -0,0 +1,182 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getCustomers method in the Customer resource', function () {
+ Saloon::fake([
+ GetCustomersRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'customers',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'accountManagerUserId' => 'mock-id-123',
+ ],
+ ],
+ 1 => [
+ 'type' => 'customers',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'accountManagerUserId' => 'mock-id-123',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetCustomersRequest)
+ ->filter('externalId', 'external_id-123');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetCustomersRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[externalId]', 'external_id-123');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->externalId->toBe('mock-id-123')
+ ->name->toBe('Mock value')
+ ->hourlyRate->toBe('Mock value')
+ ->accountManagerUserId->toBe('mock-id-123');
+});
+
+it('calls the postCustomers method in the Customer resource', function () {
+ $mockClient = Saloon::fake([
+ PostCustomersRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Customer;
+ $dto->externalId = 'external_id-123';
+ $dto->name = 'test name';
+ $dto->hourlyRate = 'test value';
+ $dto->accountManagerUserId = 'account_manager_user_id-123';
+
+ $request = new PostCustomersRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostCustomersRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('customers')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->name->toBe('test name')
+ ->hourlyRate->toBe('test value')
+ ->accountManagerUserId->toBe('account_manager_user_id-123')
+ );
+
+ return true;
+ });
+});
+
+it('calls the getCustomer method in the Customer resource', function () {
+ Saloon::fake([
+ GetCustomerRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'customers',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'accountManagerUserId' => 'mock-id-123',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetCustomerRequest(
+ customerId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetCustomerRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->externalId->toBe('mock-id-123')
+ ->name->toBe('Mock value')
+ ->hourlyRate->toBe('Mock value')
+ ->accountManagerUserId->toBe('mock-id-123');
+});
+
+it('calls the deleteCustomer method in the Customer resource', function () {
+ Saloon::fake([
+ DeleteCustomerRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteCustomerRequest(
+ customerId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteCustomerRequest::class);
+
+ expect($response->status())->toBe(200);
+});
+
+it('calls the patchCustomer method in the Customer resource', function () {
+ $mockClient = Saloon::fake([
+ PatchCustomerRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Customer;
+ $dto->externalId = 'external_id-123';
+ $dto->name = 'test name';
+ $dto->hourlyRate = 'test value';
+ $dto->accountManagerUserId = 'account_manager_user_id-123';
+
+ $request = new PatchCustomerRequest(customerId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchCustomerRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('customers')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->name->toBe('test name')
+ ->hourlyRate->toBe('test value')
+ ->accountManagerUserId->toBe('account_manager_user_id-123')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/DailyProgressTest.php b/tests/Requests/DailyProgressTest.php
new file mode 100644
index 0000000..8c66689
--- /dev/null
+++ b/tests/Requests/DailyProgressTest.php
@@ -0,0 +1,52 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getDailyProgresses method in the DailyProgress resource', function () {
+ Saloon::fake([
+ GetDailyProgressesRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'dailyProgresses',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'userId' => 'mock-id-123',
+ 'date' => '2025-11-22T10:40:04.065Z',
+ 'progress' => 'Mock value',
+ ],
+ ],
+ 1 => [
+ 'type' => 'dailyProgresses',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'userId' => 'mock-id-123',
+ 'date' => '2025-11-22T10:40:04.065Z',
+ 'progress' => 'Mock value',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetDailyProgressesRequest);
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetDailyProgressesRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->userId->toBe('mock-id-123')
+ ->date->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->progress->toBe('Mock value');
+});
diff --git a/tests/Requests/EntrySuggestionTest.php b/tests/Requests/EntrySuggestionTest.php
new file mode 100644
index 0000000..6d2d2b7
--- /dev/null
+++ b/tests/Requests/EntrySuggestionTest.php
@@ -0,0 +1,136 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getEntrySuggestions method in the EntrySuggestion resource', function () {
+ Saloon::fake([
+ GetEntrySuggestionsRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'entrySuggestions',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'userId' => 'mock-id-123',
+ 'date' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'budgetId' => 'mock-id-123',
+ ],
+ ],
+ 1 => [
+ 'type' => 'entrySuggestions',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'userId' => 'mock-id-123',
+ 'date' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'budgetId' => 'mock-id-123',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetEntrySuggestionsRequest)
+ ->filter('date', 'test value');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetEntrySuggestionsRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[date]', 'test value');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->ticketId->toBe('mock-id-123')
+ ->ticketNumber->toBe('Mock value')
+ ->customerId->toBe('mock-id-123')
+ ->userId->toBe('mock-id-123')
+ ->date->toBe('Mock value')
+ ->ticketTitle->toBe('Mock value')
+ ->ticketType->toBe('Mock value')
+ ->budgetId->toBe('mock-id-123');
+});
+
+it('calls the getEntrySuggestion method in the EntrySuggestion resource', function () {
+ Saloon::fake([
+ GetEntrySuggestionRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'entrySuggestions',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'userId' => 'mock-id-123',
+ 'date' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'budgetId' => 'mock-id-123',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetEntrySuggestionRequest(
+ entrySuggestionId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetEntrySuggestionRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->ticketId->toBe('mock-id-123')
+ ->ticketNumber->toBe('Mock value')
+ ->customerId->toBe('mock-id-123')
+ ->userId->toBe('mock-id-123')
+ ->date->toBe('Mock value')
+ ->ticketTitle->toBe('Mock value')
+ ->ticketType->toBe('Mock value')
+ ->budgetId->toBe('mock-id-123');
+});
+
+it('calls the deleteEntrySuggestion method in the EntrySuggestion resource', function () {
+ Saloon::fake([
+ DeleteEntrySuggestionRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteEntrySuggestionRequest(
+ entrySuggestionId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteEntrySuggestionRequest::class);
+
+ expect($response->status())->toBe(200);
+});
diff --git a/tests/Requests/EntryTest.php b/tests/Requests/EntryTest.php
new file mode 100644
index 0000000..4b9889b
--- /dev/null
+++ b/tests/Requests/EntryTest.php
@@ -0,0 +1,292 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getEntries method in the Entry resource', function () {
+ Saloon::fake([
+ GetEntriesRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'entries',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'customerName' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'hadEmergencyShift' => true,
+ 'budgetId' => 'mock-id-123',
+ 'isPaidPerHour' => true,
+ 'minutesSpent' => 42,
+ 'userId' => 'mock-id-123',
+ 'userEmail' => 'test@example.com',
+ 'userFullName' => 'Mock value',
+ 'createdByUserId' => 'mock-id-123',
+ 'createdByUserEmail' => 'test@example.com',
+ 'createdByUserFullName' => 'Mock value',
+ 'entryType' => 'Mock value',
+ 'description' => 'Mock value',
+ 'isInternal' => true,
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'invoicedAt' => '2025-11-22T10:40:04.065Z',
+ 'isInvoiced' => 'Mock value',
+ 'isBasedOnSuggestion' => true,
+ ],
+ ],
+ 1 => [
+ 'type' => 'entries',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'customerName' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'hadEmergencyShift' => true,
+ 'budgetId' => 'mock-id-123',
+ 'isPaidPerHour' => true,
+ 'minutesSpent' => 42,
+ 'userId' => 'mock-id-123',
+ 'userEmail' => 'test@example.com',
+ 'userFullName' => 'Mock value',
+ 'createdByUserId' => 'mock-id-123',
+ 'createdByUserEmail' => 'test@example.com',
+ 'createdByUserFullName' => 'Mock value',
+ 'entryType' => 'Mock value',
+ 'description' => 'Mock value',
+ 'isInternal' => true,
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'invoicedAt' => '2025-11-22T10:40:04.065Z',
+ 'isInvoiced' => 'Mock value',
+ 'isBasedOnSuggestion' => true,
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetEntriesRequest(include: 'test string'))
+ ->filter('userId', 'user_id-123')
+ ->filter('budgetId', 'budget_id-123')
+ ->filter('startedAt', '2025-01-15T10:30:00Z');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetEntriesRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[userId]', 'user_id-123');
+ expect($query)->toHaveKey('filter[budgetId]', 'budget_id-123');
+ expect($query)->toHaveKey('filter[startedAt]', '2025-01-15T10:30:00Z');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->ticketId->toBe('mock-id-123')
+ ->ticketNumber->toBe('Mock value')
+ ->ticketTitle->toBe('Mock value')
+ ->ticketType->toBe('Mock value')
+ ->customerId->toBe('mock-id-123')
+ ->customerName->toBe('Mock value')
+ ->hourlyRate->toBe('Mock value')
+ ->hadEmergencyShift->toBe(true)
+ ->budgetId->toBe('mock-id-123')
+ ->isPaidPerHour->toBe(true)
+ ->minutesSpent->toBe(42)
+ ->userId->toBe('mock-id-123')
+ ->userEmail->toBe('test@example.com')
+ ->userFullName->toBe('Mock value')
+ ->createdByUserId->toBe('mock-id-123')
+ ->createdByUserEmail->toBe('test@example.com')
+ ->createdByUserFullName->toBe('Mock value')
+ ->entryType->toBe('Mock value')
+ ->description->toBe('Mock value')
+ ->isInternal->toBe(true)
+ ->startedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->endedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->invoicedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->isInvoiced->toBe('Mock value')
+ ->isBasedOnSuggestion->toBe(true);
+});
+
+it('calls the postEntries method in the Entry resource', function () {
+ $mockClient = Saloon::fake([
+ PostEntriesRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Entry;
+ $dto->ticketId = 'ticket_id-123';
+ $dto->ticketNumber = 'test value';
+ $dto->ticketTitle = 'test value';
+ $dto->ticketType = 'test value';
+
+ $request = new PostEntriesRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostEntriesRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('entries')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->ticketId->toBe('ticket_id-123')
+ ->ticketNumber->toBe('test value')
+ ->ticketTitle->toBe('test value')
+ ->ticketType->toBe('test value')
+ );
+
+ return true;
+ });
+});
+
+it('calls the getEntry method in the Entry resource', function () {
+ Saloon::fake([
+ GetEntryRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'entries',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'ticketId' => 'mock-id-123',
+ 'ticketNumber' => 'Mock value',
+ 'ticketTitle' => 'Mock value',
+ 'ticketType' => 'Mock value',
+ 'customerId' => 'mock-id-123',
+ 'customerName' => 'Mock value',
+ 'hourlyRate' => 'Mock value',
+ 'hadEmergencyShift' => true,
+ 'budgetId' => 'mock-id-123',
+ 'isPaidPerHour' => true,
+ 'minutesSpent' => 42,
+ 'userId' => 'mock-id-123',
+ 'userEmail' => 'test@example.com',
+ 'userFullName' => 'Mock value',
+ 'createdByUserId' => 'mock-id-123',
+ 'createdByUserEmail' => 'test@example.com',
+ 'createdByUserFullName' => 'Mock value',
+ 'entryType' => 'Mock value',
+ 'description' => 'Mock value',
+ 'isInternal' => true,
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'invoicedAt' => '2025-11-22T10:40:04.065Z',
+ 'isInvoiced' => 'Mock value',
+ 'isBasedOnSuggestion' => true,
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetEntryRequest(
+ entryId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetEntryRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->ticketId->toBe('mock-id-123')
+ ->ticketNumber->toBe('Mock value')
+ ->ticketTitle->toBe('Mock value')
+ ->ticketType->toBe('Mock value')
+ ->customerId->toBe('mock-id-123')
+ ->customerName->toBe('Mock value')
+ ->hourlyRate->toBe('Mock value')
+ ->hadEmergencyShift->toBe(true)
+ ->budgetId->toBe('mock-id-123')
+ ->isPaidPerHour->toBe(true)
+ ->minutesSpent->toBe(42)
+ ->userId->toBe('mock-id-123')
+ ->userEmail->toBe('test@example.com')
+ ->userFullName->toBe('Mock value')
+ ->createdByUserId->toBe('mock-id-123')
+ ->createdByUserEmail->toBe('test@example.com')
+ ->createdByUserFullName->toBe('Mock value')
+ ->entryType->toBe('Mock value')
+ ->description->toBe('Mock value')
+ ->isInternal->toBe(true)
+ ->startedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->endedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->invoicedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->isInvoiced->toBe('Mock value')
+ ->isBasedOnSuggestion->toBe(true);
+});
+
+it('calls the deleteEntry method in the Entry resource', function () {
+ Saloon::fake([
+ DeleteEntryRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteEntryRequest(
+ entryId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteEntryRequest::class);
+
+ expect($response->status())->toBe(200);
+});
+
+it('calls the patchEntry method in the Entry resource', function () {
+ $mockClient = Saloon::fake([
+ PatchEntryRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Entry;
+ $dto->ticketId = 'ticket_id-123';
+ $dto->ticketNumber = 'test value';
+ $dto->ticketTitle = 'test value';
+ $dto->ticketType = 'test value';
+
+ $request = new PatchEntryRequest(entryId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchEntryRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('entries')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->ticketId->toBe('ticket_id-123')
+ ->ticketNumber->toBe('test value')
+ ->ticketTitle->toBe('test value')
+ ->ticketType->toBe('test value')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/EventTest.php b/tests/Requests/EventTest.php
new file mode 100644
index 0000000..6ca98b6
--- /dev/null
+++ b/tests/Requests/EventTest.php
@@ -0,0 +1,42 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the postEvents method in the Event resource', function () {
+ $mockClient = Saloon::fake([
+ PostEventsRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Event;
+ $dto->userId = 'user_id-123';
+ $dto->budgetId = 'budget_id-123';
+ $dto->ticketId = 'ticket_id-123';
+ $dto->sourceId = 'source_id-123';
+
+ $request = new PostEventsRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostEventsRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('events')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->userId->toBe('user_id-123')
+ ->budgetId->toBe('budget_id-123')
+ ->ticketId->toBe('ticket_id-123')
+ ->sourceId->toBe('source_id-123')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/MarkAsExportedTest.php b/tests/Requests/MarkAsExportedTest.php
new file mode 100644
index 0000000..94e238e
--- /dev/null
+++ b/tests/Requests/MarkAsExportedTest.php
@@ -0,0 +1,42 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the postOvertimeMarkAsExported method in the MarkAsExported resource', function () {
+ $mockClient = Saloon::fake([
+ PostOvertimeMarkAsExportedRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\MarkAsExported;
+ $dto->entryId = 'entry_id-123';
+ $dto->overtimeTypeId = 'overtime_type_id-123';
+ $dto->startedAt = \Carbon\Carbon::parse('2025-01-15T10:30:00Z');
+ $dto->endedAt = \Carbon\Carbon::parse('2025-01-15T10:30:00Z');
+
+ $request = new PostOvertimeMarkAsExportedRequest(overtimeId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostOvertimeMarkAsExportedRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('markAsExporteds')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->entryId->toBe('entry_id-123')
+ ->overtimeTypeId->toBe('overtime_type_id-123')
+ ->startedAt->toEqual(new \Carbon\Carbon('2025-01-15T10:30:00Z'))
+ ->endedAt->toEqual(new \Carbon\Carbon('2025-01-15T10:30:00Z'))
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/MarkAsInvoicedTest.php b/tests/Requests/MarkAsInvoicedTest.php
new file mode 100644
index 0000000..0982dd2
--- /dev/null
+++ b/tests/Requests/MarkAsInvoicedTest.php
@@ -0,0 +1,42 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the postEntryMarkAsInvoiced method in the MarkAsInvoiced resource', function () {
+ $mockClient = Saloon::fake([
+ PostEntryMarkAsInvoicedRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\MarkAsInvoiced;
+ $dto->ticketId = 'ticket_id-123';
+ $dto->ticketNumber = 'test value';
+ $dto->ticketTitle = 'test value';
+ $dto->ticketType = 'test value';
+
+ $request = new PostEntryMarkAsInvoicedRequest(entryId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostEntryMarkAsInvoicedRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('markAsInvoiceds')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->ticketId->toBe('ticket_id-123')
+ ->ticketNumber->toBe('test value')
+ ->ticketTitle->toBe('test value')
+ ->ticketType->toBe('test value')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/OvertimeTest.php b/tests/Requests/OvertimeTest.php
new file mode 100644
index 0000000..368e45c
--- /dev/null
+++ b/tests/Requests/OvertimeTest.php
@@ -0,0 +1,82 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getOvertimes method in the Overtime resource', function () {
+ Saloon::fake([
+ GetOvertimesRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'overtimes',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'entryId' => 'mock-id-123',
+ 'overtimeTypeId' => 'mock-id-123',
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'percentages' => 'Mock value',
+ 'approvedAt' => '2025-11-22T10:40:04.065Z',
+ 'approvedByUserId' => 'mock-id-123',
+ 'exportedAt' => '2025-11-22T10:40:04.065Z',
+ ],
+ ],
+ 1 => [
+ 'type' => 'overtimes',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'entryId' => 'mock-id-123',
+ 'overtimeTypeId' => 'mock-id-123',
+ 'startedAt' => '2025-11-22T10:40:04.065Z',
+ 'endedAt' => '2025-11-22T10:40:04.065Z',
+ 'percentages' => 'Mock value',
+ 'approvedAt' => '2025-11-22T10:40:04.065Z',
+ 'approvedByUserId' => 'mock-id-123',
+ 'exportedAt' => '2025-11-22T10:40:04.065Z',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetOvertimesRequest)
+ ->filter('startedAt', '2025-01-15T10:30:00Z')
+ ->filter('endedAt', '2025-01-15T10:30:00Z')
+ ->filter('isApproved', true);
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetOvertimesRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[startedAt]', '2025-01-15T10:30:00Z');
+ expect($query)->toHaveKey('filter[endedAt]', '2025-01-15T10:30:00Z');
+ expect($query)->toHaveKey('filter[isApproved]', true);
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->entryId->toBe('mock-id-123')
+ ->overtimeTypeId->toBe('mock-id-123')
+ ->startedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->endedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->percentages->toBe('Mock value')
+ ->approvedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->approvedByUserId->toBe('mock-id-123')
+ ->exportedAt->toEqual(new Carbon('2025-11-22T10:40:04.065Z'));
+});
diff --git a/tests/Requests/TeamTest.php b/tests/Requests/TeamTest.php
new file mode 100644
index 0000000..0f3b254
--- /dev/null
+++ b/tests/Requests/TeamTest.php
@@ -0,0 +1,154 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getTeams method in the Team resource', function () {
+ Saloon::fake([
+ GetTeamsRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'teams',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ ],
+ ],
+ 1 => [
+ 'type' => 'teams',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetTeamsRequest);
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetTeamsRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->externalId->toBe('mock-id-123')
+ ->name->toBe('Mock value');
+});
+
+it('calls the postTeams method in the Team resource', function () {
+ $mockClient = Saloon::fake([
+ PostTeamsRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Team;
+ $dto->externalId = 'external_id-123';
+ $dto->name = 'test name';
+
+ $request = new PostTeamsRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostTeamsRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('teams')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->name->toBe('test name')
+ );
+
+ return true;
+ });
+});
+
+it('calls the getTeam method in the Team resource', function () {
+ Saloon::fake([
+ GetTeamRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'teams',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'name' => 'Mock value',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetTeamRequest(
+ teamId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetTeamRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->externalId->toBe('mock-id-123')
+ ->name->toBe('Mock value');
+});
+
+it('calls the deleteTeam method in the Team resource', function () {
+ Saloon::fake([
+ DeleteTeamRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteTeamRequest(
+ teamId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteTeamRequest::class);
+
+ expect($response->status())->toBe(200);
+});
+
+it('calls the patchTeam method in the Team resource', function () {
+ $mockClient = Saloon::fake([
+ PatchTeamRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\Team;
+ $dto->externalId = 'external_id-123';
+ $dto->name = 'test name';
+
+ $request = new PatchTeamRequest(teamId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchTeamRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('teams')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->name->toBe('test name')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/Requests/TimeSpentTotalTest.php b/tests/Requests/TimeSpentTotalTest.php
new file mode 100644
index 0000000..5bffc14
--- /dev/null
+++ b/tests/Requests/TimeSpentTotalTest.php
@@ -0,0 +1,74 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getTimeSpentTotals method in the TimeSpentTotal resource', function () {
+ Saloon::fake([
+ GetTimeSpentTotalsRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'timeSpentTotals',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'start' => '2025-11-22T10:40:04.065Z',
+ 'end' => '2025-11-22T10:40:04.065Z',
+ 'internalMinutes' => 42,
+ 'billableMinutes' => 42,
+ 'periodUnit' => 'Mock value',
+ 'periodValue' => 42,
+ ],
+ ],
+ 1 => [
+ 'type' => 'timeSpentTotals',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'start' => '2025-11-22T10:40:04.065Z',
+ 'end' => '2025-11-22T10:40:04.065Z',
+ 'internalMinutes' => 42,
+ 'billableMinutes' => 42,
+ 'periodUnit' => 'Mock value',
+ 'periodValue' => 42,
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetTimeSpentTotalsRequest)
+ ->filter('teamId', 'team_id-123')
+ ->filter('userId', 'user_id-123');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetTimeSpentTotalsRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[teamId]', 'team_id-123');
+ expect($query)->toHaveKey('filter[userId]', 'user_id-123');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->start->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->end->toEqual(new Carbon('2025-11-22T10:40:04.065Z'))
+ ->internalMinutes->toBe(42)
+ ->billableMinutes->toBe(42)
+ ->periodUnit->toBe('Mock value')
+ ->periodValue->toBe(42);
+});
diff --git a/tests/Requests/UserCustomerHoursAggregateTest.php b/tests/Requests/UserCustomerHoursAggregateTest.php
new file mode 100644
index 0000000..d425bae
--- /dev/null
+++ b/tests/Requests/UserCustomerHoursAggregateTest.php
@@ -0,0 +1,72 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getUserCustomerHoursAggregates method in the UserCustomerHoursAggregate resource', function () {
+ Saloon::fake([
+ GetUserCustomerHoursAggregatesRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'userCustomerHoursAggregates',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'customerId' => 'mock-id-123',
+ 'userId' => 'mock-id-123',
+ 'internalMinutes' => 42,
+ 'budgetMinutes' => 42,
+ 'paidPerHourMinutes' => 42,
+ ],
+ ],
+ 1 => [
+ 'type' => 'userCustomerHoursAggregates',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'customerId' => 'mock-id-123',
+ 'userId' => 'mock-id-123',
+ 'internalMinutes' => 42,
+ 'budgetMinutes' => 42,
+ 'paidPerHourMinutes' => 42,
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetUserCustomerHoursAggregatesRequest)
+ ->filter('startedAt', '2025-01-15T10:30:00Z')
+ ->filter('endedAt', '2025-01-15T10:30:00Z')
+ ->filter('teamId', 'team_id-123');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetUserCustomerHoursAggregatesRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[startedAt]', '2025-01-15T10:30:00Z');
+ expect($query)->toHaveKey('filter[endedAt]', '2025-01-15T10:30:00Z');
+ expect($query)->toHaveKey('filter[teamId]', 'team_id-123');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->customerId->toBe('mock-id-123')
+ ->userId->toBe('mock-id-123')
+ ->internalMinutes->toBe(42)
+ ->budgetMinutes->toBe(42)
+ ->paidPerHourMinutes->toBe(42);
+});
diff --git a/tests/Requests/UserTest.php b/tests/Requests/UserTest.php
new file mode 100644
index 0000000..30ece34
--- /dev/null
+++ b/tests/Requests/UserTest.php
@@ -0,0 +1,164 @@
+timaticConnector = new Timatic\SDK\TimaticConnector;
+});
+
+it('calls the getUsers method in the User resource', function () {
+ Saloon::fake([
+ GetUsersRequest::class => MockResponse::make([
+ 'data' => [
+ 0 => [
+ 'type' => 'users',
+ 'id' => 'mock-id-1',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'email' => 'test@example.com',
+ ],
+ ],
+ 1 => [
+ 'type' => 'users',
+ 'id' => 'mock-id-2',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'email' => 'test@example.com',
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = (new GetUsersRequest)
+ ->filter('externalId', 'external_id-123');
+
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetUsersRequest::class);
+
+ // Verify filter query parameters are present
+ Saloon::assertSent(function (Request $request) {
+ $query = $request->query()->all();
+
+ expect($query)->toHaveKey('filter[externalId]', 'external_id-123');
+
+ return true;
+ });
+
+ expect($response->status())->toBe(200);
+
+ $dtoCollection = $response->dto();
+
+ expect($dtoCollection->first())
+ ->externalId->toBe('mock-id-123')
+ ->email->toBe('test@example.com');
+});
+
+it('calls the postUsers method in the User resource', function () {
+ $mockClient = Saloon::fake([
+ PostUsersRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\User;
+ $dto->externalId = 'external_id-123';
+ $dto->email = 'test@example.com';
+
+ $request = new PostUsersRequest($dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PostUsersRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('users')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->email->toBe('test@example.com')
+ );
+
+ return true;
+ });
+});
+
+it('calls the getUser method in the User resource', function () {
+ Saloon::fake([
+ GetUserRequest::class => MockResponse::make([
+ 'data' => [
+ 'type' => 'users',
+ 'id' => 'mock-id-123',
+ 'attributes' => [
+ 'externalId' => 'mock-id-123',
+ 'email' => 'test@example.com',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $request = new GetUserRequest(
+ userId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(GetUserRequest::class);
+
+ expect($response->status())->toBe(200);
+
+ $dto = $response->dto();
+
+ expect($dto)
+ ->externalId->toBe('mock-id-123')
+ ->email->toBe('test@example.com');
+});
+
+it('calls the deleteUser method in the User resource', function () {
+ Saloon::fake([
+ DeleteUserRequest::class => MockResponse::make([], 200),
+ ]);
+
+ $request = new DeleteUserRequest(
+ userId: 'test string'
+ );
+ $response = $this->timaticConnector->send($request);
+
+ Saloon::assertSent(DeleteUserRequest::class);
+
+ expect($response->status())->toBe(200);
+});
+
+it('calls the patchUser method in the User resource', function () {
+ $mockClient = Saloon::fake([
+ PatchUserRequest::class => MockResponse::make([], 200),
+ ]);
+
+ // Create DTO with sample data
+ $dto = new \Timatic\SDK\Dto\User;
+ $dto->externalId = 'external_id-123';
+ $dto->email = 'test@example.com';
+
+ $request = new PatchUserRequest(userId: 'test string', data: $dto);
+ $this->timaticConnector->send($request);
+
+ Saloon::assertSent(PatchUserRequest::class);
+
+ $mockClient->assertSent(function (Request $request) {
+ expect($request->body()->all())
+ ->toHaveKey('data')
+ ->data->type->toBe('users')
+ ->data->attributes->scoped(fn ($attributes) => $attributes
+ ->externalId->toBe('external_id-123')
+ ->email->toBe('test@example.com')
+ );
+
+ return true;
+ });
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..dd8abe2
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,31 @@
+load();
+ }
+
+ // Set config values for testing
+ $app['config']->set('timatic.base_url', env('TIMATIC_BASE_URL', 'https://api.app.timatic.test'));
+ $app['config']->set('timatic.api_token', env('TIMATIC_API_TOKEN'));
+ }
+}