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 + +[![Tests](https://github.com/Timatic/timatic-php-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/Timatic/timatic-php-sdk/actions/workflows/tests.yml) +[![Code Style](https://github.com/Timatic/timatic-php-sdk/actions/workflows/code-style.yml/badge.svg)](https://github.com/Timatic/timatic-php-sdk/actions/workflows/code-style.yml) +[![Static Analysis](https://github.com/Timatic/timatic-php-sdk/actions/workflows/static-analysis.yml/badge.svg)](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')); + } +}