From 3378a4eef2a336df7cdff84da5c5346a4df19cdc Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Fri, 19 Dec 2025 23:29:09 +0100 Subject: [PATCH 1/3] feat: add includes to collection requests --- generator/JsonApiDtoGenerator.php | 107 ++++++++ generator/JsonApiFactoryGenerator.php | 14 + generator/JsonApiRequestGenerator.php | 85 +++++- .../CollectionRequestTestGenerator.php | 3 +- .../TestGenerators/Traits/DtoAssertions.php | 15 ++ generator/generate.php | 35 ++- src/Dto/Approve.php | 5 + src/Dto/Budget.php | 13 + src/Dto/Entry.php | 8 + src/Dto/MarkAsExported.php | 5 + src/Dto/MarkAsInvoiced.php | 8 + src/Dto/Overtime.php | 5 + src/Dto/UserCustomerHoursAggregate.php | 8 + src/Hydration/Hydrator.php | 4 +- .../Budget/GetBudgetsCollectionRequest.php | 61 ++++- ...BudgetTimeSpentTotalsCollectionRequest.php | 2 +- src/Requests/{ => Concerns}/HasFilters.php | 2 +- src/Requests/Concerns/HasIncludes.php | 43 +++ .../GetCustomersCollectionRequest.php | 2 +- .../GetBudgetEntriesExportRequest.php | 6 - .../Entry/GetEntriesCollectionRequest.php | 69 ++++- .../GetEntrySuggestionsCollectionRequest.php | 2 +- .../GetOvertimesCollectionRequest.php | 2 +- .../GetTimeSpentTotalsCollectionRequest.php | 2 +- .../User/GetUsersCollectionRequest.php | 4 +- ...stomerHoursAggregatesCollectionRequest.php | 2 +- tests/Feature/IncludesTest.php | 249 ++++++++++++++++++ tests/Requests/BudgetTest.php | 2 +- tests/Requests/EntryTest.php | 2 +- 29 files changed, 707 insertions(+), 58 deletions(-) rename src/Requests/{ => Concerns}/HasFilters.php (91%) create mode 100644 src/Requests/Concerns/HasIncludes.php create mode 100644 tests/Feature/IncludesTest.php diff --git a/generator/JsonApiDtoGenerator.php b/generator/JsonApiDtoGenerator.php index ca72a04..3835682 100644 --- a/generator/JsonApiDtoGenerator.php +++ b/generator/JsonApiDtoGenerator.php @@ -10,19 +10,27 @@ use Crescat\SaloonSdkGenerator\Generator; use Crescat\SaloonSdkGenerator\Helpers\NameHelper; use Crescat\SaloonSdkGenerator\Helpers\Utils; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Nette\PhpGenerator\ClassType; +use Nette\PhpGenerator\Literal; use Nette\PhpGenerator\PhpFile; use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class JsonApiDtoGenerator extends Generator { protected array $generated = []; + protected ApiSpecification $specification; + public function generate(ApiSpecification $specification): PhpFile|array { + $this->specification = $specification; + if ($specification->components) { foreach ($specification->components->schemas as $className => $schema) { $this->generateModelClass(NameHelper::safeClassName($className), $schema); @@ -55,6 +63,9 @@ protected function generateModelClass(string $className, Schema $schema): PhpFil $this->addPropertyToClass($classType, $namespace, $propertyName, $propertySpec); } + // Add relationship properties + $this->addRelationshipProperties($classType, $namespace, $schema); + // Add imports $namespace->addUse(Model::class); $namespace->addUse(Property::class); @@ -177,4 +188,100 @@ protected function mapType(string $type, ?string $format = null): string 'null' => 'null', }; } + + /** + * Add relationship properties to the DTO class + */ + protected function addRelationshipProperties(ClassType $classType, $namespace, Schema $schema): void + { + // Check if schema has relationships + if (! isset($schema->properties['relationships'])) { + return; + } + + $relationships = $schema->properties['relationships']; + + if (! isset($relationships->properties) || ! is_array($relationships->properties)) { + return; + } + + // Import required classes + $namespace->addUse(Relationship::class); + $namespace->addUse(RelationType::class); + $namespace->addUse(Collection::class); + + foreach ($relationships->properties as $relationName => $relationSpec) { + $relationType = $this->detectRelationType($relationName, $relationSpec); + $relatedModel = $this->detectRelatedModel($relationName); + + if (! $relatedModel) { + // Skip if we can't determine the related model + echo " โš ๏ธ Skipping relationship '{$relationName}' - model not found\n"; + + continue; + } + + // Check if related model schema exists + if (! isset($this->specification->components->schemas[$relatedModel])) { + echo " โš ๏ธ Skipping relationship '{$relationName}' - model '{$relatedModel}' not found in schemas\n"; + + continue; + } + + // Import related model + $namespace->addUse("Timatic\\Dto\\{$relatedModel}"); + + // Create property + $property = $classType->addProperty($relationName) + ->setPublic() + ->setNullable(true) + ->setValue(null); // Add default value + + // Set type based on relationship type + // Use FQN for Collection and related model to avoid backslash prefix + if ($relationType === 'Many') { + $property->setType('null|\\Illuminate\\Support\\Collection'); + $property->addComment("@var Collection|null"); + } else { + $property->setType("null|\\Timatic\\Dto\\{$relatedModel}"); + } + + // Add Relationship attribute (use full class name for attribute) + $property->addAttribute(Relationship::class, [ + new Literal("{$relatedModel}::class"), + new Literal("RelationType::{$relationType}"), + ]); + } + } + + /** + * Detect relationship type (One or Many) from relationship name + */ + protected function detectRelationType(string $relationName, $relationSpec): string + { + // Plural relationship names are typically "to-many" + if (Str::plural($relationName) === $relationName) { + return 'Many'; + } + + // Singular names are "to-one" + return 'One'; + } + + /** + * Detect related model class name from relationship name + */ + protected function detectRelatedModel(string $relationName): ?string + { + // Convert relationship name to model name + // Examples: + // budgetType -> BudgetType + // entries -> Entry + // currentPeriod -> Period + + $singular = Str::singular($relationName); + $modelName = NameHelper::dtoClassName($singular); + + return $modelName; + } } diff --git a/generator/JsonApiFactoryGenerator.php b/generator/JsonApiFactoryGenerator.php index 27bbf70..a19e53e 100644 --- a/generator/JsonApiFactoryGenerator.php +++ b/generator/JsonApiFactoryGenerator.php @@ -95,6 +95,20 @@ protected function getDtoProperties(string $dtoFullClass): array continue; } + // Skip relationship properties (they have Relationship attribute) + $hasRelationshipAttribute = false; + foreach ($property->getAttributes() as $attribute) { + $attrName = $attribute->getName(); + if ($attrName === 'Relationship' || str_ends_with($attrName, '\\Relationship')) { + $hasRelationshipAttribute = true; + break; + } + } + + if ($hasRelationshipAttribute) { + continue; + } + // Check if property has DateTime attribute $isDateTime = ! empty($property->getAttributes(DateTime::class)); diff --git a/generator/JsonApiRequestGenerator.php b/generator/JsonApiRequestGenerator.php index 1f189bf..40d682a 100644 --- a/generator/JsonApiRequestGenerator.php +++ b/generator/JsonApiRequestGenerator.php @@ -9,6 +9,7 @@ use Crescat\SaloonSdkGenerator\Data\Generator\Parameter; use Crescat\SaloonSdkGenerator\Generators\RequestGenerator; use Crescat\SaloonSdkGenerator\Helpers\MethodGeneratorHelper; +use Illuminate\Support\Str; use Nette\PhpGenerator\ClassType; use Nette\PhpGenerator\PhpFile; use Saloon\Http\Response; @@ -16,7 +17,8 @@ use Timatic\Generator\TestGenerators\Traits\DtoHelperTrait; use Timatic\Hydration\Facades\Hydrator; use Timatic\Hydration\Model; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; +use Timatic\Requests\Concerns\HasIncludes; class JsonApiRequestGenerator extends RequestGenerator { @@ -86,6 +88,15 @@ protected function customizeRequestClass(ClassType $classType, $namespace, Endpo $namespace->addUse(HasFilters::class); $classType->addTrait(HasFilters::class); } + + // Add HasIncludes trait if endpoint has include parameter + if ($this->hasIncludeParameter($endpoint)) { + $namespace->addUse(HasIncludes::class); + $classType->addTrait(HasIncludes::class); + + // Add relationship-specific include methods + $this->addIncludeMethods($classType, $namespace, $endpoint); + } } // Add hydration support to GET, POST, and PATCH requests @@ -121,11 +132,21 @@ protected function customizeConstructor($classConstructor, ClassType $classType, } /** - * Hook: Filter out filter* query parameters (handled by HasFilters trait) + * Hook: Filter out filter* and include query parameters (handled by traits) */ protected function shouldIncludeQueryParameter(string $paramName): bool { - return ! str_starts_with($paramName, 'filter'); + // Filter out filter* parameters (handled by HasFilters trait) + if (str_starts_with($paramName, 'filter')) { + return false; + } + + // Filter out include parameter (handled by HasIncludes trait) + if ($paramName === 'include') { + return false; + } + + return true; } /** @@ -133,12 +154,10 @@ protected function shouldIncludeQueryParameter(string $paramName): bool */ 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 + + // For other cases with query parameters, use parent implementation if (! empty($queryParams)) { - $classType->addMethod('defaultQuery') - ->setProtected() - ->setReturnType('array') - ->addBody("return array_filter(['include' => \$this->include]);"); + parent::generateDefaultQueryMethod($classType, $namespace, $queryParams, $endpoint); } } @@ -168,6 +187,20 @@ protected function hasFilterParameters(Endpoint $endpoint): bool return false; } + /** + * Check if endpoint has include query parameter + */ + protected function hasIncludeParameter(Endpoint $endpoint): bool + { + foreach ($endpoint->queryParameters as $param) { + if ($param->name === 'include') { + return true; + } + } + + return false; + } + /** * Determine if request should have hydration support */ @@ -229,4 +262,40 @@ protected function addHydrationSupport(ClassType $classType, $namespace, Endpoin $method->addBody(');'); } } + + /** + * Add relationship-specific include methods to request class + */ + protected function addIncludeMethods(ClassType $classType, $namespace, Endpoint $endpoint): void + { + // Get the DTO class name for this endpoint + $dtoClassName = $this->getDtoClassName($endpoint); + + // Check if schema exists in specification + if (! isset($this->specification->components->schemas[$dtoClassName])) { + return; + } + + $schema = $this->specification->components->schemas[$dtoClassName]; + + // Check if schema has relationships + if (! isset($schema->properties['relationships'])) { + return; + } + + $relationships = $schema->properties['relationships']; + + // Generate include method for each relationship + if (isset($relationships->properties) && is_array($relationships->properties)) { + foreach ($relationships->properties as $relationName => $relationSpec) { + $methodName = 'include'.Str::studly($relationName); + + $classType->addMethod($methodName) + ->setPublic() + ->setReturnType('static') + ->addComment("Include the {$relationName} relationship in the response") + ->addBody("return \$this->addInclude('{$relationName}');"); + } + } + } } diff --git a/generator/TestGenerators/CollectionRequestTestGenerator.php b/generator/TestGenerators/CollectionRequestTestGenerator.php index 3b2f02a..eae9977 100644 --- a/generator/TestGenerators/CollectionRequestTestGenerator.php +++ b/generator/TestGenerators/CollectionRequestTestGenerator.php @@ -215,7 +215,8 @@ protected function getNonFilterQueryParameters(Endpoint $endpoint): string $params = []; foreach ($endpoint->queryParameters as $parameter) { - if (! str_starts_with($parameter->name, 'filter[')) { + // Skip filter parameters and include parameter (handled by HasIncludes trait) + if (! str_starts_with($parameter->name, 'filter[') && $parameter->name !== 'include') { $paramName = NameHelper::safeVariableName($parameter->name); $value = match ($parameter->type) { 'string' => "'test string'", diff --git a/generator/TestGenerators/Traits/DtoAssertions.php b/generator/TestGenerators/Traits/DtoAssertions.php index 3c05636..84e33c8 100644 --- a/generator/TestGenerators/Traits/DtoAssertions.php +++ b/generator/TestGenerators/Traits/DtoAssertions.php @@ -77,6 +77,21 @@ protected function getDtoPropertiesFromGeneratedCode(string $dtoClassName): arra continue; } + // Skip relationship properties (they have Relationship attribute) + $hasRelationshipAttribute = false; + foreach ($property->getAttributes() as $attribute) { + $attrName = $attribute->getName(); + // Check for both short name and full class name + if ($attrName === 'Relationship' || str_ends_with($attrName, '\\Relationship')) { + $hasRelationshipAttribute = true; + break; + } + } + + if ($hasRelationshipAttribute) { + continue; + } + $type = $property->getType(); $typeName = null; diff --git a/generator/generate.php b/generator/generate.php index 56aab1f..79fd4b5 100644 --- a/generator/generate.php +++ b/generator/generate.php @@ -18,21 +18,28 @@ use Timatic\Generator\JsonApiResourceGenerator; // Download OpenAPI spec -echo "๐Ÿ“ฅ Downloading OpenAPI specification...\n"; -$openApiJson = file_get_contents('https://api.app.timatic.test/docs/json', false, stream_context_create([ - 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - ], -])); - -if (! $openApiJson) { - echo "โŒ Failed to download OpenAPI specification\n"; - exit(1); -} +$openApiPath = __DIR__.'/../openapi.json'; + +if (file_exists($openApiPath)) { + echo "๐Ÿ“ฅ Using existing OpenAPI specification...\n"; + echo "โœ… OpenAPI specification found\n\n"; +} else { + echo "๐Ÿ“ฅ Downloading OpenAPI specification...\n"; + $openApiJson = file_get_contents('https://api.app.timatic.test/docs/json', false, stream_context_create([ + 'ssl' => [ + '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"; + file_put_contents($openApiPath, $openApiJson); + echo "โœ… OpenAPI specification downloaded\n\n"; +} // Clean up previously generated folders echo "๐Ÿงน Cleaning up previously generated files...\n"; diff --git a/src/Dto/Approve.php b/src/Dto/Approve.php index aef906e..a8befa9 100644 --- a/src/Dto/Approve.php +++ b/src/Dto/Approve.php @@ -6,7 +6,9 @@ use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class Approve extends Model { @@ -45,4 +47,7 @@ class Approve extends Model #[Property] #[DateTime] public ?\Carbon\Carbon $updatedAt; + + #[Relationship(Entry::class, RelationType::One)] + public ?Entry $entry = null; } diff --git a/src/Dto/Budget.php b/src/Dto/Budget.php index 78efabe..14092eb 100644 --- a/src/Dto/Budget.php +++ b/src/Dto/Budget.php @@ -4,9 +4,12 @@ namespace Timatic\Dto; +use Illuminate\Support\Collection; use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class Budget extends Model { @@ -61,4 +64,14 @@ class Budget extends Model #[Property] public ?string $supervisorUserId; + + /** @var Collection|null */ + #[Relationship(Entry::class, RelationType::Many)] + public ?Collection $entries = null; + + #[Relationship(BudgetType::class, RelationType::One)] + public ?BudgetType $budgetType = null; + + #[Relationship(Customer::class, RelationType::One)] + public ?Customer $customer = null; } diff --git a/src/Dto/Entry.php b/src/Dto/Entry.php index a0a8d55..646b62c 100644 --- a/src/Dto/Entry.php +++ b/src/Dto/Entry.php @@ -6,7 +6,9 @@ use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class Entry extends Model { @@ -95,4 +97,10 @@ class Entry extends Model #[Property] public ?bool $isBasedOnSuggestion; + + #[Relationship(Customer::class, RelationType::One)] + public ?Customer $customer = null; + + #[Relationship(Budget::class, RelationType::One)] + public ?Budget $budget = null; } diff --git a/src/Dto/MarkAsExported.php b/src/Dto/MarkAsExported.php index 992e09a..2b12dfe 100644 --- a/src/Dto/MarkAsExported.php +++ b/src/Dto/MarkAsExported.php @@ -6,7 +6,9 @@ use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class MarkAsExported extends Model { @@ -45,4 +47,7 @@ class MarkAsExported extends Model #[Property] #[DateTime] public ?\Carbon\Carbon $updatedAt; + + #[Relationship(Entry::class, RelationType::One)] + public ?Entry $entry = null; } diff --git a/src/Dto/MarkAsInvoiced.php b/src/Dto/MarkAsInvoiced.php index f0c1643..2ad2a37 100644 --- a/src/Dto/MarkAsInvoiced.php +++ b/src/Dto/MarkAsInvoiced.php @@ -6,7 +6,9 @@ use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class MarkAsInvoiced extends Model { @@ -95,4 +97,10 @@ class MarkAsInvoiced extends Model #[Property] public ?bool $isBasedOnSuggestion; + + #[Relationship(Customer::class, RelationType::One)] + public ?Customer $customer = null; + + #[Relationship(Budget::class, RelationType::One)] + public ?Budget $budget = null; } diff --git a/src/Dto/Overtime.php b/src/Dto/Overtime.php index 220ff6f..174230a 100644 --- a/src/Dto/Overtime.php +++ b/src/Dto/Overtime.php @@ -6,7 +6,9 @@ use Timatic\Hydration\Attributes\DateTime; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class Overtime extends Model { @@ -45,4 +47,7 @@ class Overtime extends Model #[Property] #[DateTime] public ?\Carbon\Carbon $updatedAt; + + #[Relationship(Entry::class, RelationType::One)] + public ?Entry $entry = null; } diff --git a/src/Dto/UserCustomerHoursAggregate.php b/src/Dto/UserCustomerHoursAggregate.php index bd792cd..92cd633 100644 --- a/src/Dto/UserCustomerHoursAggregate.php +++ b/src/Dto/UserCustomerHoursAggregate.php @@ -5,7 +5,9 @@ namespace Timatic\Dto; use Timatic\Hydration\Attributes\Property; +use Timatic\Hydration\Attributes\Relationship; use Timatic\Hydration\Model; +use Timatic\Hydration\RelationType; class UserCustomerHoursAggregate extends Model { @@ -23,4 +25,10 @@ class UserCustomerHoursAggregate extends Model #[Property] public ?int $paidPerHourMinutes; + + #[Relationship(Customer::class, RelationType::One)] + public ?Customer $customer = null; + + #[Relationship(User::class, RelationType::One)] + public ?User $user = null; } diff --git a/src/Hydration/Hydrator.php b/src/Hydration/Hydrator.php index 37264a9..49b1929 100644 --- a/src/Hydration/Hydrator.php +++ b/src/Hydration/Hydrator.php @@ -167,7 +167,9 @@ protected function hydrateRelations(ReflectionClass $reflectionClass, array $ite $relationItem = $relationship['data']; $includedItem = $included[$relationItem['id'].'-'.$relationItem['type']] ?? null; - $model->{$relationshipName} = $this->hydrate($relationModel, $includedItem, $included); + if (! is_null($includedItem)) { + $model->{$relationshipName} = $this->hydrate($relationModel, $includedItem, $included); + } } } } diff --git a/src/Requests/Budget/GetBudgetsCollectionRequest.php b/src/Requests/Budget/GetBudgetsCollectionRequest.php index 685dbd6..047b0af 100644 --- a/src/Requests/Budget/GetBudgetsCollectionRequest.php +++ b/src/Requests/Budget/GetBudgetsCollectionRequest.php @@ -10,7 +10,8 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\Budget; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; +use Timatic\Requests\Concerns\HasIncludes; /** * getBudgets @@ -18,11 +19,60 @@ class GetBudgetsCollectionRequest extends Request implements Paginatable { use HasFilters; + use HasIncludes; protected $model = Budget::class; protected Method $method = Method::GET; + /** + * Include the entries relationship in the response + */ + public function includeEntries(): static + { + return $this->addInclude('entries'); + } + + /** + * Include the budgetType relationship in the response + */ + public function includeBudgetType(): static + { + return $this->addInclude('budgetType'); + } + + /** + * Include the currentPeriod relationship in the response + */ + public function includeCurrentPeriod(): static + { + return $this->addInclude('currentPeriod'); + } + + /** + * Include the lastPeriod relationship in the response + */ + public function includeLastPeriod(): static + { + return $this->addInclude('lastPeriod'); + } + + /** + * Include the customer relationship in the response + */ + public function includeCustomer(): static + { + return $this->addInclude('customer'); + } + + /** + * Include the allowedUsers relationship in the response + */ + public function includeAllowedUsers(): static + { + return $this->addInclude('allowedUsers'); + } + public function createDtoFromResponse(Response $response): mixed { return Hydrator::hydrateCollection( @@ -37,12 +87,5 @@ public function resolveEndpoint(): string return '/budgets'; } - public function __construct( - protected ?string $include = null, - ) {} - - protected function defaultQuery(): array - { - return array_filter(['include' => $this->include]); - } + public function __construct() {} } diff --git a/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsCollectionRequest.php b/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsCollectionRequest.php index a31c76e..533ba16 100644 --- a/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsCollectionRequest.php +++ b/src/Requests/BudgetTimeSpentTotal/GetBudgetTimeSpentTotalsCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\BudgetTimeSpentTotal; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getBudgetTimeSpentTotals diff --git a/src/Requests/HasFilters.php b/src/Requests/Concerns/HasFilters.php similarity index 91% rename from src/Requests/HasFilters.php rename to src/Requests/Concerns/HasFilters.php index 7b5914e..684b6fe 100644 --- a/src/Requests/HasFilters.php +++ b/src/Requests/Concerns/HasFilters.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Timatic\Requests; +namespace Timatic\Requests\Concerns; use Timatic\Filtering\Operator; diff --git a/src/Requests/Concerns/HasIncludes.php b/src/Requests/Concerns/HasIncludes.php new file mode 100644 index 0000000..3f3bcd2 --- /dev/null +++ b/src/Requests/Concerns/HasIncludes.php @@ -0,0 +1,43 @@ +query()->add('include', implode(',', $relationships)); + + return $this; + } + + /** + * Add a single include to the request (for fluent method chaining) + */ + protected function addInclude(string $relation): static + { + $includes = $this->getCurrentIncludes(); + $includes[] = $relation; + + return $this->include(...$includes); + } + + /** + * Get current includes from query string + */ + protected function getCurrentIncludes(): array + { + $currentIncludeString = $this->query()->get('include'); + + if ($currentIncludeString === null) { + return []; + } + + return explode(',', $currentIncludeString); + } +} diff --git a/src/Requests/Customer/GetCustomersCollectionRequest.php b/src/Requests/Customer/GetCustomersCollectionRequest.php index 621b517..a26c40d 100644 --- a/src/Requests/Customer/GetCustomersCollectionRequest.php +++ b/src/Requests/Customer/GetCustomersCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\Customer; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getCustomers diff --git a/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php b/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php index eba2a5d..cfaa43d 100644 --- a/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php +++ b/src/Requests/EntriesExport/GetBudgetEntriesExportRequest.php @@ -21,11 +21,5 @@ public function resolveEndpoint(): string 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/GetEntriesCollectionRequest.php b/src/Requests/Entry/GetEntriesCollectionRequest.php index e4671b7..06c7f04 100644 --- a/src/Requests/Entry/GetEntriesCollectionRequest.php +++ b/src/Requests/Entry/GetEntriesCollectionRequest.php @@ -10,7 +10,8 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\Entry; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; +use Timatic\Requests\Concerns\HasIncludes; /** * getEntries @@ -18,11 +19,68 @@ class GetEntriesCollectionRequest extends Request implements Paginatable { use HasFilters; + use HasIncludes; protected $model = Entry::class; protected Method $method = Method::GET; + /** + * Include the personalOvertime relationship in the response + */ + public function includePersonalOvertime(): static + { + return $this->addInclude('personalOvertime'); + } + + /** + * Include the customerOvertime relationship in the response + */ + public function includeCustomerOvertime(): static + { + return $this->addInclude('customerOvertime'); + } + + /** + * Include the correctionEntryCorrection relationship in the response + */ + public function includeCorrectionEntryCorrection(): static + { + return $this->addInclude('correctionEntryCorrection'); + } + + /** + * Include the correctedEntryCorrection relationship in the response + */ + public function includeCorrectedEntryCorrection(): static + { + return $this->addInclude('correctedEntryCorrection'); + } + + /** + * Include the newEntryCorrection relationship in the response + */ + public function includeNewEntryCorrection(): static + { + return $this->addInclude('newEntryCorrection'); + } + + /** + * Include the customer relationship in the response + */ + public function includeCustomer(): static + { + return $this->addInclude('customer'); + } + + /** + * Include the budget relationship in the response + */ + public function includeBudget(): static + { + return $this->addInclude('budget'); + } + public function createDtoFromResponse(Response $response): mixed { return Hydrator::hydrateCollection( @@ -37,12 +95,5 @@ public function resolveEndpoint(): string return '/entries'; } - public function __construct( - protected ?string $include = null, - ) {} - - protected function defaultQuery(): array - { - return array_filter(['include' => $this->include]); - } + public function __construct() {} } diff --git a/src/Requests/EntrySuggestion/GetEntrySuggestionsCollectionRequest.php b/src/Requests/EntrySuggestion/GetEntrySuggestionsCollectionRequest.php index 5bf1a74..c48dada 100644 --- a/src/Requests/EntrySuggestion/GetEntrySuggestionsCollectionRequest.php +++ b/src/Requests/EntrySuggestion/GetEntrySuggestionsCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\EntrySuggestion; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getEntrySuggestions diff --git a/src/Requests/Overtime/GetOvertimesCollectionRequest.php b/src/Requests/Overtime/GetOvertimesCollectionRequest.php index 4575e61..df7f7c2 100644 --- a/src/Requests/Overtime/GetOvertimesCollectionRequest.php +++ b/src/Requests/Overtime/GetOvertimesCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\Overtime; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getOvertimes diff --git a/src/Requests/TimeSpentTotal/GetTimeSpentTotalsCollectionRequest.php b/src/Requests/TimeSpentTotal/GetTimeSpentTotalsCollectionRequest.php index 03fb0fc..6ec3faf 100644 --- a/src/Requests/TimeSpentTotal/GetTimeSpentTotalsCollectionRequest.php +++ b/src/Requests/TimeSpentTotal/GetTimeSpentTotalsCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\TimeSpentTotal; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getTimeSpentTotals diff --git a/src/Requests/User/GetUsersCollectionRequest.php b/src/Requests/User/GetUsersCollectionRequest.php index f0d3291..4e022a3 100644 --- a/src/Requests/User/GetUsersCollectionRequest.php +++ b/src/Requests/User/GetUsersCollectionRequest.php @@ -10,7 +10,8 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\User; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; +use Timatic\Requests\Concerns\HasIncludes; /** * getUsers @@ -18,6 +19,7 @@ class GetUsersCollectionRequest extends Request implements Paginatable { use HasFilters; + use HasIncludes; protected $model = User::class; diff --git a/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesCollectionRequest.php b/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesCollectionRequest.php index e5e89d8..4eb89cd 100644 --- a/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesCollectionRequest.php +++ b/src/Requests/UserCustomerHoursAggregate/GetUserCustomerHoursAggregatesCollectionRequest.php @@ -10,7 +10,7 @@ use Saloon\PaginationPlugin\Contracts\Paginatable; use Timatic\Dto\UserCustomerHoursAggregate; use Timatic\Hydration\Facades\Hydrator; -use Timatic\Requests\HasFilters; +use Timatic\Requests\Concerns\HasFilters; /** * getUserCustomerHoursAggregates diff --git a/tests/Feature/IncludesTest.php b/tests/Feature/IncludesTest.php new file mode 100644 index 0000000..fac36dd --- /dev/null +++ b/tests/Feature/IncludesTest.php @@ -0,0 +1,249 @@ +timaticConnector = new TimaticConnector; +}); + +it('can include relationships using fluent API', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [ + [ + 'type' => 'budgets', + 'id' => 'budget-1', + 'attributes' => [ + 'title' => 'Test Budget', + 'budgetTypeId' => 'type-1', + 'customerId' => 'customer-1', + ], + 'relationships' => [ + 'budgetType' => [ + 'data' => ['type' => 'budgetTypes', 'id' => 'type-1'], + ], + 'customer' => [ + 'data' => ['type' => 'customers', 'id' => 'customer-1'], + ], + 'entries' => [ + 'data' => [ + ['type' => 'entries', 'id' => 'entry-1'], + ['type' => 'entries', 'id' => 'entry-2'], + ], + ], + ], + ], + ], + 'included' => [ + [ + 'type' => 'budgetTypes', + 'id' => 'type-1', + 'attributes' => ['title' => 'Fixed Price'], + ], + [ + 'type' => 'customers', + 'id' => 'customer-1', + 'attributes' => ['name' => 'Acme Corp'], + ], + [ + 'type' => 'entries', + 'id' => 'entry-1', + 'attributes' => ['description' => 'Entry 1'], + ], + [ + 'type' => 'entries', + 'id' => 'entry-2', + 'attributes' => ['description' => 'Entry 2'], + ], + ], + ], 200), + ]); + + $request = (new GetBudgetsCollectionRequest) + ->includeBudgetType() + ->includeCustomer() + ->includeEntries(); + + $response = $this->timaticConnector->send($request); + + // Verify include parameter was sent + Saloon::assertSent(function (GetBudgetsCollectionRequest $request) { + $query = $request->query()->all(); + expect($query)->toHaveKey('include', 'budgetType,customer,entries'); + + return true; + }); + + $budgets = $response->dto(); + $budget = $budgets->first(); + + // Verify relationships were hydrated + expect($budget) + ->toBeInstanceOf(Budget::class) + ->budgetType->toBeInstanceOf(BudgetType::class) + ->budgetType->title->toBe('Fixed Price') + ->customer->toBeInstanceOf(Customer::class) + ->customer->name->toBe('Acme Corp') + ->entries->toHaveCount(2) + ->entries->first()->toBeInstanceOf(Entry::class) + ->entries->first()->description->toBe('Entry 1'); +}); + +it('can use generic include method', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [], + ], 200), + ]); + + $request = (new GetBudgetsCollectionRequest) + ->include('budgetType', 'customer', 'entries'); + + $this->timaticConnector->send($request); + + Saloon::assertSent(function (GetBudgetsCollectionRequest $request) { + $query = $request->query()->all(); + expect($query)->toHaveKey('include', 'budgetType,customer,entries'); + + return true; + }); +}); + +it('can chain multiple include methods', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [], + ], 200), + ]); + + $request = (new GetBudgetsCollectionRequest) + ->includeBudgetType() + ->includeEntries() + ->includeCustomer() + ->includeCurrentPeriod(); + + $this->timaticConnector->send($request); + + Saloon::assertSent(function (GetBudgetsCollectionRequest $request) { + $query = $request->query()->all(); + expect($query)->toHaveKey('include', 'budgetType,entries,customer,currentPeriod'); + + return true; + }); +}); + +it('does not send include parameter when no includes are added', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [], + ], 200), + ]); + + $request = new GetBudgetsCollectionRequest; + + $this->timaticConnector->send($request); + + Saloon::assertSent(function (GetBudgetsCollectionRequest $request) { + $query = $request->query()->all(); + expect($query)->not->toHaveKey('include'); + + return true; + }); +}); + +it('handles missing included data gracefully', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [ + [ + 'type' => 'budgets', + 'id' => 'budget-1', + 'attributes' => [ + 'title' => 'Test Budget', + ], + 'relationships' => [ + 'budgetType' => [ + 'data' => ['type' => 'budgetTypes', 'id' => 'type-1'], + ], + ], + ], + ], + 'included' => [], // No included data + ], 200), + ]); + + $request = (new GetBudgetsCollectionRequest) + ->includeBudgetType(); + + $response = $this->timaticConnector->send($request); + $budgets = $response->dto(); + $budget = $budgets->first(); + + // Relationship should be null when not in included array + expect($budget->budgetType)->toBeNull(); +}); + +it('handles many relationships correctly', function () { + Saloon::fake([ + GetBudgetsCollectionRequest::class => MockResponse::make([ + 'data' => [ + [ + 'type' => 'budgets', + 'id' => 'budget-1', + 'attributes' => [ + 'title' => 'Test Budget', + ], + 'relationships' => [ + 'entries' => [ + 'data' => [ + ['type' => 'entries', 'id' => 'entry-1'], + ['type' => 'entries', 'id' => 'entry-2'], + ['type' => 'entries', 'id' => 'entry-3'], + ], + ], + ], + ], + ], + 'included' => [ + [ + 'type' => 'entries', + 'id' => 'entry-1', + 'attributes' => ['description' => 'First Entry'], + ], + [ + 'type' => 'entries', + 'id' => 'entry-2', + 'attributes' => ['description' => 'Second Entry'], + ], + [ + 'type' => 'entries', + 'id' => 'entry-3', + 'attributes' => ['description' => 'Third Entry'], + ], + ], + ], 200), + ]); + + $request = (new GetBudgetsCollectionRequest) + ->includeEntries(); + + $response = $this->timaticConnector->send($request); + $budget = $response->dto()->first(); + + expect($budget->entries) + ->toHaveCount(3) + ->each(fn ($entry) => $entry->toBeInstanceOf(Entry::class)); + + expect($budget->entries->pluck('description')->toArray())->toBe([ + 'First Entry', + 'Second Entry', + 'Third Entry', + ]); +}); diff --git a/tests/Requests/BudgetTest.php b/tests/Requests/BudgetTest.php index 49447d3..ec94158 100644 --- a/tests/Requests/BudgetTest.php +++ b/tests/Requests/BudgetTest.php @@ -64,7 +64,7 @@ ], 200), ]); - $request = (new GetBudgetsCollectionRequest(include: 'test string')) + $request = (new GetBudgetsCollectionRequest) ->filter('customerId', 'customer_id-123') ->filter('budgetTypeId', 'budget_type_id-123') ->filter('isArchived', true); diff --git a/tests/Requests/EntryTest.php b/tests/Requests/EntryTest.php index e4f0b58..45ee91a 100644 --- a/tests/Requests/EntryTest.php +++ b/tests/Requests/EntryTest.php @@ -86,7 +86,7 @@ ], 200), ]); - $request = (new GetEntriesCollectionRequest(include: 'test string')) + $request = (new GetEntriesCollectionRequest) ->filter('userId', 'user_id-123') ->filter('budgetId', 'budget_id-123') ->filter('startedAt', '2025-01-15T10:30:00Z'); From 295374067df8924d1d33c936b4dfc0def34b5eaa Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Sat, 20 Dec 2025 00:02:02 +0100 Subject: [PATCH 2/3] add tests for all includes for coverage --- .../CollectionRequestTestGenerator.php | 221 +++++++++++++++++- .../pest-collection-request-test-func.stub | 11 +- tests/Feature/IncludesTest.php | 23 -- tests/Requests/BudgetTest.php | 78 ++++++- tests/Requests/BudgetTimeSpentTotalTest.php | 7 +- tests/Requests/BudgetTypeTest.php | 6 +- tests/Requests/CustomerTest.php | 6 +- tests/Requests/DailyProgressTest.php | 6 +- tests/Requests/EntrySuggestionTest.php | 7 +- tests/Requests/EntryTest.php | 55 ++++- tests/Requests/OvertimeTest.php | 7 +- tests/Requests/TeamTest.php | 6 +- tests/Requests/TimeSpentTotalTest.php | 7 +- .../UserCustomerHoursAggregateTest.php | 7 +- tests/Requests/UserTest.php | 6 +- 15 files changed, 363 insertions(+), 90 deletions(-) diff --git a/generator/TestGenerators/CollectionRequestTestGenerator.php b/generator/TestGenerators/CollectionRequestTestGenerator.php index eae9977..14aa4bb 100644 --- a/generator/TestGenerators/CollectionRequestTestGenerator.php +++ b/generator/TestGenerators/CollectionRequestTestGenerator.php @@ -60,11 +60,35 @@ public function replaceStubVariables(string $functionStub, Endpoint $endpoint): // Only include filter assertions block if there are filters if (! empty($filterData['assertions'])) { $filterAssertionBlock = $this->generateFilterAssertionBlock($filterData['assertions']); - $functionStub = str_replace('{{ filterAssertionBlock }}', $filterAssertionBlock, $functionStub); + $functionStub = str_replace('{{ filterAssertionBlock }}', $filterAssertionBlock."\n\t\t", $functionStub); } else { $functionStub = str_replace('{{ filterAssertionBlock }}', '', $functionStub); } + // Add include chain and assertions + $includeData = $this->generateIncludeChainWithData($endpoint); + + // Add newline before include chain if there are filters + if (! empty($filterData['chain']) && ! empty($includeData['chain'])) { + $functionStub = str_replace('{{ includeChain }}', "\n\t\t".$includeData['chain'], $functionStub); + } else { + $functionStub = str_replace('{{ includeChain }}', $includeData['chain'], $functionStub); + } + + // Only include assertion if there are includes + if (! empty($includeData['assertion'])) { + $functionStub = str_replace('{{ includeAssertion }}', $includeData['assertion'], $functionStub); + } else { + $functionStub = str_replace('{{ includeAssertion }}', '', $functionStub); + } + + // Add relationship assertions if there are includes + if (! empty($includeData['relationshipAssertions'])) { + $functionStub = str_replace('{{ relationshipAssertions }}', "\n\t\t".$includeData['relationshipAssertions'], $functionStub); + } else { + $functionStub = str_replace('{{ relationshipAssertions }}', '', $functionStub); + } + // Add non-filter query parameters (like 'include') $nonFilterParams = $this->getNonFilterQueryParameters($endpoint); $functionStub = str_replace('{{ nonFilterParams }}', $nonFilterParams, $functionStub); @@ -110,8 +134,12 @@ public function generateMockData(Endpoint $endpoint): array $resourceType = $this->getResourceTypeFromEndpoint($endpoint); - // Generate 2-3 items for collections - return [ + // Get relationships for this endpoint + $relationships = $this->getRelationshipsFromSchema($dtoClassName); + $includeData = $this->generateIncludeChainWithData($endpoint); + $hasIncludes = ! empty($includeData['chain']); + + $data = [ 'data' => [ [ 'type' => $resourceType, @@ -125,6 +153,56 @@ public function generateMockData(Endpoint $endpoint): array ], ], ]; + + // Add relationships and included data if this endpoint has includes + if ($hasIncludes && ! empty($relationships)) { + $included = []; + $relationshipsData = []; + + foreach ($relationships as $index => $relationName) { + $relatedModel = $this->detectRelatedModel($relationName); + + // Skip if the related model doesn't exist in the schema + if (! isset($this->specification->components->schemas[$relatedModel])) { + continue; + } + + $relationType = strtolower(\Illuminate\Support\Str::plural($relatedModel)); + + // Check if this is a "Many" relationship + $isMany = \Illuminate\Support\Str::plural($relationName) === $relationName; + + if ($isMany) { + // For "Many" relationships, data should be an array of objects + $relationshipsData[$relationName] = [ + 'data' => [ + ['type' => $relationType, 'id' => "related-{$relationName}-1"], + ], + ]; + } else { + // For "One" relationships, data is a single object + $relationshipsData[$relationName] = [ + 'data' => ['type' => $relationType, 'id' => "related-{$relationName}-1"], + ]; + } + + // Add to included array with minimal attributes + $included[] = [ + 'type' => $relationType, + 'id' => "related-{$relationName}-1", + 'attributes' => [], + ]; + } + + // Add relationships to both data items + $data['data'][0]['relationships'] = $relationshipsData; + $data['data'][1]['relationships'] = $relationshipsData; + + // Add included array + $data['included'] = $included; + } + + return $data; } /** @@ -132,9 +210,8 @@ public function generateMockData(Endpoint $endpoint): array */ protected function generateFilterAssertionBlock(string $assertions): string { - $stub = file_get_contents(__DIR__.'/stubs/pest-filter-assertion-block.stub'); - - return str_replace('{{ filterAssertions }}', $assertions, $stub); + // Just return the assertions with proper indentation, no wrapper needed + return $assertions; } /** @@ -231,4 +308,136 @@ protected function getNonFilterQueryParameters(Endpoint $endpoint): string return implode(', ', $params); } + + /** + * Check if endpoint has include query parameter + */ + protected function hasIncludeParameter(Endpoint $endpoint): bool + { + foreach ($endpoint->queryParameters as $param) { + if ($param->name === 'include') { + return true; + } + } + + return false; + } + + /** + * Get relationships from the DTO schema + */ + protected function getRelationshipsFromSchema(string $dtoClassName): array + { + if (! isset($this->specification->components->schemas[$dtoClassName])) { + return []; + } + + $schema = $this->specification->components->schemas[$dtoClassName]; + + if (! isset($schema->properties['relationships'])) { + return []; + } + + $relationships = $schema->properties['relationships']; + + if (! isset($relationships->properties) || ! is_array($relationships->properties)) { + return []; + } + + return array_keys($relationships->properties); + } + + /** + * Generate include method chain with data for testing + */ + protected function generateIncludeChainWithData(Endpoint $endpoint): array + { + if (! $this->hasIncludeParameter($endpoint)) { + return [ + 'chain' => '', + 'assertion' => '', + 'relationshipAssertions' => '', + ]; + } + + $dtoClassName = $this->getDtoClassName($endpoint); + $relationships = $this->getRelationshipsFromSchema($dtoClassName); + + if (empty($relationships)) { + return [ + 'chain' => '', + 'assertion' => '', + 'relationshipAssertions' => '', + ]; + } + + // Filter out relationships where the model doesn't exist + $testRelationships = array_filter($relationships, function ($relationName) { + $relatedModel = $this->detectRelatedModel($relationName); + + return isset($this->specification->components->schemas[$relatedModel]); + }); + + // Generate include method calls + $includeCalls = []; + foreach ($testRelationships as $relationName) { + $methodName = 'include'.\Illuminate\Support\Str::studly($relationName); + $includeCalls[] = "->{$methodName}()"; + } + + $includeChain = implode("\n\t\t", $includeCalls); + + // Generate assertion for include parameter + $expectedInclude = implode(',', $testRelationships); + $assertion = "expect(\$query)->toHaveKey('include', '{$expectedInclude}');"; + + // Generate relationship hydration assertions + $relationshipAssertions = $this->generateRelationshipAssertions($testRelationships); + + return [ + 'chain' => $includeChain, + 'assertion' => $assertion, + 'relationshipAssertions' => $relationshipAssertions, + ]; + } + + /** + * Detect the related model class name from relationship name + */ + protected function detectRelatedModel(string $relationName): string + { + // Convert to singular studly case (e.g., budgetType -> BudgetType, entries -> Entry) + return \Illuminate\Support\Str::studly(\Illuminate\Support\Str::singular($relationName)); + } + + /** + * Generate assertions to verify relationships are hydrated + */ + protected function generateRelationshipAssertions(array $relationships): string + { + $assertions = []; + + foreach ($relationships as $relationName) { + $relatedModel = $this->detectRelatedModel($relationName); + $propertyName = \Illuminate\Support\Str::camel($relationName); + + // Skip if the related model doesn't exist in the schema + if (! isset($this->specification->components->schemas[$relatedModel])) { + continue; + } + + // Check if this is likely a "Many" relationship (plural name) + $isMany = \Illuminate\Support\Str::plural($relationName) === $relationName; + + if ($isMany) { + // For collection relationships, verify it's not null + $assertions[] = "->{$propertyName}->not->toBeNull()"; + } else { + // For single relationships, verify it's an instance of the related model + $assertions[] = "->{$propertyName}->toBeInstanceOf(\\Timatic\\Dto\\{$relatedModel}::class)"; + } + } + + return implode("\n\t\t", $assertions); + } } diff --git a/generator/TestGenerators/stubs/pest-collection-request-test-func.stub b/generator/TestGenerators/stubs/pest-collection-request-test-func.stub index 1d260a4..fbce88c 100644 --- a/generator/TestGenerators/stubs/pest-collection-request-test-func.stub +++ b/generator/TestGenerators/stubs/pest-collection-request-test-func.stub @@ -4,17 +4,20 @@ it('{{ testDescription }}', function () { ]); $request = (new {{ requestClass }}({{ nonFilterParams }})) - {{ filterChain }}; + {{ filterChain }}{{ includeChain }}; $response = $this->{{ clientName }}->send($request); - Saloon::assertSent({{ requestClass }}::class); - {{ filterAssertionBlock }} + Saloon::assertSent(function ({{ requestClass }} $request) { + $query = $request->query()->all(); + {{ filterAssertionBlock }}{{ includeAssertion }} + return true; + }); expect($response->status())->toBe(200); $dtoCollection = $response->dto(); expect($dtoCollection->first()) - {{ dtoAssertions }}; + {{ dtoAssertions }}{{ relationshipAssertions }}; }); diff --git a/tests/Feature/IncludesTest.php b/tests/Feature/IncludesTest.php index fac36dd..a3cb57a 100644 --- a/tests/Feature/IncludesTest.php +++ b/tests/Feature/IncludesTest.php @@ -116,29 +116,6 @@ }); }); -it('can chain multiple include methods', function () { - Saloon::fake([ - GetBudgetsCollectionRequest::class => MockResponse::make([ - 'data' => [], - ], 200), - ]); - - $request = (new GetBudgetsCollectionRequest) - ->includeBudgetType() - ->includeEntries() - ->includeCustomer() - ->includeCurrentPeriod(); - - $this->timaticConnector->send($request); - - Saloon::assertSent(function (GetBudgetsCollectionRequest $request) { - $query = $request->query()->all(); - expect($query)->toHaveKey('include', 'budgetType,entries,customer,currentPeriod'); - - return true; - }); -}); - it('does not send include parameter when no includes are added', function () { Saloon::fake([ GetBudgetsCollectionRequest::class => MockResponse::make([ diff --git a/tests/Requests/BudgetTest.php b/tests/Requests/BudgetTest.php index ec94158..fcb34f6 100644 --- a/tests/Requests/BudgetTest.php +++ b/tests/Requests/BudgetTest.php @@ -39,6 +39,28 @@ 'renewalFrequency' => 'Mock value', 'supervisorUserId' => 'mock-id-123', ], + 'relationships' => [ + 'entries' => [ + 'data' => [ + 0 => [ + 'type' => 'entries', + 'id' => 'related-entries-1', + ], + ], + ], + 'budgetType' => [ + 'data' => [ + 'type' => 'budgettypes', + 'id' => 'related-budgetType-1', + ], + ], + 'customer' => [ + 'data' => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + ], + ], + ], ], 1 => [ 'type' => 'budgets', @@ -59,6 +81,45 @@ 'renewalFrequency' => 'Mock value', 'supervisorUserId' => 'mock-id-123', ], + 'relationships' => [ + 'entries' => [ + 'data' => [ + 0 => [ + 'type' => 'entries', + 'id' => 'related-entries-1', + ], + ], + ], + 'budgetType' => [ + 'data' => [ + 'type' => 'budgettypes', + 'id' => 'related-budgetType-1', + ], + ], + 'customer' => [ + 'data' => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + ], + ], + ], + ], + ], + 'included' => [ + 0 => [ + 'type' => 'entries', + 'id' => 'related-entries-1', + 'attributes' => [], + ], + 1 => [ + 'type' => 'budgettypes', + 'id' => 'related-budgetType-1', + 'attributes' => [], + ], + 2 => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + 'attributes' => [], ], ], ], 200), @@ -67,19 +128,19 @@ $request = (new GetBudgetsCollectionRequest) ->filter('customerId', 'customer_id-123') ->filter('budgetTypeId', 'budget_type_id-123') - ->filter('isArchived', true); + ->filter('isArchived', true) + ->includeEntries() + ->includeBudgetType() + ->includeCustomer(); $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetBudgetsCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetBudgetsCollectionRequest $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); + expect($query)->toHaveKey('include', 'entries,budgetType,customer'); return true; }); @@ -102,7 +163,10 @@ ->initialMinutes->toBe(42) ->isArchived->toBe(true) ->renewalFrequency->toBe('Mock value') - ->supervisorUserId->toBe('mock-id-123'); + ->supervisorUserId->toBe('mock-id-123') + ->entries->not->toBeNull() + ->budgetType->toBeInstanceOf(\Timatic\Dto\BudgetType::class) + ->customer->toBeInstanceOf(\Timatic\Dto\Customer::class); }); it('calls the postBudgets method in the Budget resource', function () { diff --git a/tests/Requests/BudgetTimeSpentTotalTest.php b/tests/Requests/BudgetTimeSpentTotalTest.php index f43ee9f..fdb2cc2 100644 --- a/tests/Requests/BudgetTimeSpentTotalTest.php +++ b/tests/Requests/BudgetTimeSpentTotalTest.php @@ -4,7 +4,6 @@ use Carbon\Carbon; use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Laravel\Facades\Saloon; use Timatic\Requests\BudgetTimeSpentTotal\GetBudgetTimeSpentTotalsCollectionRequest; @@ -47,12 +46,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetBudgetTimeSpentTotalsCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetBudgetTimeSpentTotalsCollectionRequest $request) { $query = $request->query()->all(); - expect($query)->toHaveKey('filter[budgetId]', 'budget_id-123'); return true; diff --git a/tests/Requests/BudgetTypeTest.php b/tests/Requests/BudgetTypeTest.php index 96292a6..66fb45f 100644 --- a/tests/Requests/BudgetTypeTest.php +++ b/tests/Requests/BudgetTypeTest.php @@ -52,7 +52,11 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetBudgetTypesCollectionRequest::class); + Saloon::assertSent(function (GetBudgetTypesCollectionRequest $request) { + $query = $request->query()->all(); + + return true; + }); expect($response->status())->toBe(200); diff --git a/tests/Requests/CustomerTest.php b/tests/Requests/CustomerTest.php index 0c851cb..d3f7da1 100644 --- a/tests/Requests/CustomerTest.php +++ b/tests/Requests/CustomerTest.php @@ -48,12 +48,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetCustomersCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetCustomersCollectionRequest $request) { $query = $request->query()->all(); - expect($query)->toHaveKey('filter[externalId]', 'external_id-123'); return true; diff --git a/tests/Requests/DailyProgressTest.php b/tests/Requests/DailyProgressTest.php index 6ea676e..5945f2a 100644 --- a/tests/Requests/DailyProgressTest.php +++ b/tests/Requests/DailyProgressTest.php @@ -41,7 +41,11 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetDailyProgressesCollectionRequest::class); + Saloon::assertSent(function (GetDailyProgressesCollectionRequest $request) { + $query = $request->query()->all(); + + return true; + }); expect($response->status())->toBe(200); diff --git a/tests/Requests/EntrySuggestionTest.php b/tests/Requests/EntrySuggestionTest.php index a11e1c9..aca7641 100644 --- a/tests/Requests/EntrySuggestionTest.php +++ b/tests/Requests/EntrySuggestionTest.php @@ -3,7 +3,6 @@ // auto-generated use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Laravel\Facades\Saloon; use Timatic\Requests\EntrySuggestion\DeleteEntrySuggestionRequest; use Timatic\Requests\EntrySuggestion\GetEntrySuggestionRequest; @@ -54,12 +53,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetEntrySuggestionsCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetEntrySuggestionsCollectionRequest $request) { $query = $request->query()->all(); - expect($query)->toHaveKey('filter[date]', 'test value'); return true; diff --git a/tests/Requests/EntryTest.php b/tests/Requests/EntryTest.php index 45ee91a..4ef8545 100644 --- a/tests/Requests/EntryTest.php +++ b/tests/Requests/EntryTest.php @@ -50,6 +50,20 @@ 'isInvoiced' => 'Mock value', 'isBasedOnSuggestion' => true, ], + 'relationships' => [ + 'customer' => [ + 'data' => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + ], + ], + 'budget' => [ + 'data' => [ + 'type' => 'budgets', + 'id' => 'related-budget-1', + ], + ], + ], ], 1 => [ 'type' => 'entries', @@ -81,6 +95,32 @@ 'isInvoiced' => 'Mock value', 'isBasedOnSuggestion' => true, ], + 'relationships' => [ + 'customer' => [ + 'data' => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + ], + ], + 'budget' => [ + 'data' => [ + 'type' => 'budgets', + 'id' => 'related-budget-1', + ], + ], + ], + ], + ], + 'included' => [ + 0 => [ + 'type' => 'customers', + 'id' => 'related-customer-1', + 'attributes' => [], + ], + 1 => [ + 'type' => 'budgets', + 'id' => 'related-budget-1', + 'attributes' => [], ], ], ], 200), @@ -89,19 +129,18 @@ $request = (new GetEntriesCollectionRequest) ->filter('userId', 'user_id-123') ->filter('budgetId', 'budget_id-123') - ->filter('startedAt', '2025-01-15T10:30:00Z'); + ->filter('startedAt', '2025-01-15T10:30:00Z') + ->includeCustomer() + ->includeBudget(); $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetEntriesCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetEntriesCollectionRequest $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'); + expect($query)->toHaveKey('include', 'customer,budget'); return true; }); @@ -135,7 +174,9 @@ ->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); + ->isBasedOnSuggestion->toBe(true) + ->customer->toBeInstanceOf(\Timatic\Dto\Customer::class) + ->budget->toBeInstanceOf(\Timatic\Dto\Budget::class); }); it('calls the postEntries method in the Entry resource', function () { diff --git a/tests/Requests/OvertimeTest.php b/tests/Requests/OvertimeTest.php index ed7f7a2..3843bdf 100644 --- a/tests/Requests/OvertimeTest.php +++ b/tests/Requests/OvertimeTest.php @@ -4,7 +4,6 @@ use Carbon\Carbon; use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Laravel\Facades\Saloon; use Timatic\Requests\Overtime\GetOvertimesCollectionRequest; @@ -55,12 +54,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetOvertimesCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetOvertimesCollectionRequest $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); diff --git a/tests/Requests/TeamTest.php b/tests/Requests/TeamTest.php index 2fe02aa..004c5a6 100644 --- a/tests/Requests/TeamTest.php +++ b/tests/Requests/TeamTest.php @@ -43,7 +43,11 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetTeamsCollectionRequest::class); + Saloon::assertSent(function (GetTeamsCollectionRequest $request) { + $query = $request->query()->all(); + + return true; + }); expect($response->status())->toBe(200); diff --git a/tests/Requests/TimeSpentTotalTest.php b/tests/Requests/TimeSpentTotalTest.php index 497ddbc..da5edd3 100644 --- a/tests/Requests/TimeSpentTotalTest.php +++ b/tests/Requests/TimeSpentTotalTest.php @@ -4,7 +4,6 @@ use Carbon\Carbon; use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Laravel\Facades\Saloon; use Timatic\Requests\TimeSpentTotal\GetTimeSpentTotalsCollectionRequest; @@ -50,12 +49,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetTimeSpentTotalsCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetTimeSpentTotalsCollectionRequest $request) { $query = $request->query()->all(); - expect($query)->toHaveKey('filter[teamId]', 'team_id-123'); expect($query)->toHaveKey('filter[userId]', 'user_id-123'); diff --git a/tests/Requests/UserCustomerHoursAggregateTest.php b/tests/Requests/UserCustomerHoursAggregateTest.php index 4aa38da..484b617 100644 --- a/tests/Requests/UserCustomerHoursAggregateTest.php +++ b/tests/Requests/UserCustomerHoursAggregateTest.php @@ -3,7 +3,6 @@ // auto-generated use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Laravel\Facades\Saloon; use Timatic\Requests\UserCustomerHoursAggregate\GetUserCustomerHoursAggregatesCollectionRequest; @@ -48,12 +47,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetUserCustomerHoursAggregatesCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetUserCustomerHoursAggregatesCollectionRequest $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'); diff --git a/tests/Requests/UserTest.php b/tests/Requests/UserTest.php index ca1c4fc..8805a58 100644 --- a/tests/Requests/UserTest.php +++ b/tests/Requests/UserTest.php @@ -50,12 +50,8 @@ $response = $this->timaticConnector->send($request); - Saloon::assertSent(GetUsersCollectionRequest::class); - - // Verify filter query parameters are present - Saloon::assertSent(function (Request $request) { + Saloon::assertSent(function (GetUsersCollectionRequest $request) { $query = $request->query()->all(); - expect($query)->toHaveKey('filter[externalId]', 'external_id-123'); return true; From b3e701c9ca95357ce2b2d5c2d11bc91696f69c3a Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Sun, 21 Dec 2025 14:23:52 +0100 Subject: [PATCH 3/3] throw errors --- src/TimaticConnector.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/TimaticConnector.php b/src/TimaticConnector.php index 4c3d9c5..63def6f 100644 --- a/src/TimaticConnector.php +++ b/src/TimaticConnector.php @@ -5,6 +5,7 @@ use Saloon\Http\Connector; use Saloon\Http\Request; use Saloon\PaginationPlugin\Contracts\HasPagination; +use Saloon\Traits\Plugins\AlwaysThrowOnErrors; use Timatic\Pagination\JsonApiPaginator; use Timatic\Responses\TimaticResponse; @@ -13,6 +14,8 @@ */ class TimaticConnector extends Connector implements HasPagination { + use AlwaysThrowOnErrors; + protected function defaultHeaders(): array { $headers = [