diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2d3b81c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,119 @@ +name: CI Pipeline + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + name: Code Formatting + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache/files + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Install Composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run Laravel Pint + run: ./vendor/bin/pint --test --verbose + + phpstan: + runs-on: ubuntu-latest + name: Static Analysis + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache/files + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Install Composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run PHPStan + run: ./vendor/bin/phpstan analyse + + tests: + runs-on: ubuntu-latest + name: Unit Tests + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache/files + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Install Composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run Unit Tests + run: ./vendor/bin/pest + + coverage: + runs-on: ubuntu-latest + name: Test Coverage + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, xdebug + coverage: xdebug + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache/files + key: dependencies-composer-${{ hashFiles('composer.json') }} + + - name: Install Composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run Test Coverage + run: ./vendor/bin/pest --coverage --coverage-clover ./coverage.xml --min=80 + + - name: Upload to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODE_COV_TOKEN }} + files: ./coverage.xml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index 121c37d..0000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Lint Check - -on: - pull_request: - branches: [main] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - lint: - runs-on: ubuntu-latest - name: Code Linting - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv - coverage: none - - - name: Cache Composer dependencies - uses: actions/cache@v3 - with: - path: ~/.composer/cache/files - key: dependencies-composer-${{ hashFiles('composer.json') }} - - - name: Install Composer dependencies - run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - - name: Run Laravel Pint - run: ./vendor/bin/pint --test --verbose - - - name: Add PR comment on failure - if: failure() - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: '**Code formatting issues detected**\n\nPlease run `./vendor/bin/pint` to fix formatting issues before merging.' - }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a903cbe..c88ddf3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,38 +5,9 @@ on: types: [published] jobs: - code-quality: - runs-on: ubuntu-latest - name: Code Quality Check - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv - coverage: none - - - name: Cache Composer dependencies - uses: actions/cache@v3 - with: - path: ~/.composer/cache/files - key: dependencies-composer-${{ hashFiles('composer.json') }} - - - name: Install Composer dependencies - run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - - name: Run Laravel Pint - run: ./vendor/bin/pint --test - submit-to-packagist: runs-on: ubuntu-latest name: Submit to Packagist - needs: code-quality - if: success() steps: - name: Submit package to Packagist diff --git a/.gitignore b/.gitignore index 93dbb77..97d5232 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ Homestead.yaml /build /dist /coverage + +# Miscellaneous +/.phpunit.cache +coverage.xml diff --git a/composer.json b/composer.json index ea99d47..895e33a 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,6 @@ "array conversion" ], "homepage": "https://github.com/lumosolutions/actionable", - "autoload": { - "psr-4": { - "LumoSolutions\\Actionable\\": "src/" - } - }, "authors": [ { "name": "Richard Anderson", @@ -23,11 +18,39 @@ "role": "Developer" } ], + "autoload": { + "psr-4": { + "LumoSolutions\\Actionable\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LumoSolutions\\Actionable\\Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, "require": { "php": ">=8.2" }, "require-dev": { - "laravel/pint": "^1.22" + "larastan/larastan": "^2.9||^3.0", + "laravel/pint": "^1.22", + "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", + "pestphp/pest": "^3.0" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } }, "extra": { "laravel": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..fa4c250 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + level: 5 + paths: + - src + - tests + excludePaths: + - tests/*/*Test.php + tmpDir: build/phpstan diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ff32b5a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + tests + + + + + + + + ./src + + + diff --git a/src/Analysis/ClassMetadata.php b/src/Analysis/ClassMetadata.php index 3f54517..bc43c88 100644 --- a/src/Analysis/ClassMetadata.php +++ b/src/Analysis/ClassMetadata.php @@ -2,13 +2,13 @@ namespace LumoSolutions\Actionable\Analysis; -class ClassMetadata +readonly class ClassMetadata { /** @var FieldMetadata[] */ - public readonly array $constructorFields; + public array $constructorFields; /** @var FieldMetadata[] */ - public readonly array $properties; + public array $properties; /** * @param FieldMetadata[] $constructorFields @@ -20,28 +20,6 @@ public function __construct(array $constructorFields, array $properties) $this->properties = $properties; } - public function getConstructorField(string $propertyName): ?FieldMetadata - { - foreach ($this->constructorFields as $field) { - if ($field->propertyName === $propertyName) { - return $field; - } - } - - return null; - } - - public function getProperty(string $propertyName): ?FieldMetadata - { - foreach ($this->properties as $property) { - if ($property->propertyName === $propertyName) { - return $property; - } - } - - return null; - } - public function getVisibleProperties(): array { return array_filter($this->properties, fn (FieldMetadata $field) => ! $field->shouldIgnore()); diff --git a/src/Analysis/FieldMetadata.php b/src/Analysis/FieldMetadata.php index b925221..d302dec 100644 --- a/src/Analysis/FieldMetadata.php +++ b/src/Analysis/FieldMetadata.php @@ -2,18 +2,18 @@ namespace LumoSolutions\Actionable\Analysis; -class FieldMetadata +readonly class FieldMetadata { public function __construct( - public readonly string $propertyName, - public readonly string $fieldName, - public readonly ?string $type = null, - public readonly bool $ignore = false, - public readonly ?string $dateFormat = null, - public readonly ?string $arrayOf = null, - public readonly bool $hasDefault = false, - public readonly mixed $defaultValue = null, - public readonly bool $allowsNull = false + public string $propertyName, + public string $fieldName, + public ?string $type = null, + public bool $ignore = false, + public ?string $dateFormat = null, + public ?string $arrayOf = null, + public bool $hasDefault = false, + public mixed $defaultValue = null, + public bool $allowsNull = false ) {} public function isDateTime(): bool diff --git a/src/Attributes/ArrayOf.php b/src/Attributes/ArrayOf.php index 2ea1b2c..ad36b95 100644 --- a/src/Attributes/ArrayOf.php +++ b/src/Attributes/ArrayOf.php @@ -5,7 +5,7 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] -class ArrayOf +readonly class ArrayOf { public function __construct( public string $class diff --git a/src/Attributes/DateFormat.php b/src/Attributes/DateFormat.php index 38e5381..4ec3deb 100644 --- a/src/Attributes/DateFormat.php +++ b/src/Attributes/DateFormat.php @@ -5,7 +5,7 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] -class DateFormat +readonly class DateFormat { public function __construct( public string $format = 'Y-m-d H:i:s' diff --git a/src/Attributes/FieldName.php b/src/Attributes/FieldName.php index 53327b1..9f4e265 100644 --- a/src/Attributes/FieldName.php +++ b/src/Attributes/FieldName.php @@ -5,7 +5,7 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] -class FieldName +readonly class FieldName { public function __construct( public string $name diff --git a/src/Attributes/Ignore.php b/src/Attributes/Ignore.php index 34671bd..bb2a327 100644 --- a/src/Attributes/Ignore.php +++ b/src/Attributes/Ignore.php @@ -5,4 +5,4 @@ use Attribute; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] -class Ignore {} +readonly class Ignore {} diff --git a/src/Console/BaseStubCommand.php b/src/Console/BaseStubCommand.php index b3cd34c..05d2b5b 100644 --- a/src/Console/BaseStubCommand.php +++ b/src/Console/BaseStubCommand.php @@ -3,9 +3,12 @@ namespace LumoSolutions\Actionable\Console; use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; +use function Illuminate\Filesystem\join_paths; + abstract class BaseStubCommand extends Command { protected function rootNamespace(): string @@ -13,40 +16,36 @@ protected function rootNamespace(): string return 'App\\'; } - protected function subDirectory(): string - { - return ''; - } + abstract protected function subDirectory(): string; public static function packageStubBasePath(): string { - return __DIR__.'/../stubs'; + return join_paths(__DIR__, '..', '..', 'stubs'); } public static function applicationStubBasePath(): string { - return base_path('stubs/lumosolutions/actionable'); + return join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable'); } protected function resolveStubPath(string $stubName): string { - $applicationStubPath = self::applicationStubBasePath().'/'.$stubName; + $applicationStubPath = join_paths(self::applicationStubBasePath(), $stubName); - // If the specific stub exists in the application, use it if (File::exists($applicationStubPath)) { return $applicationStubPath; } // Otherwise, use the package stub - return self::packageStubBasePath().'/'.$stubName; + return join_paths(self::packageStubBasePath(), $stubName); } /** * Handle the command execution. * - * @return int + * @throws FileNotFoundException */ - public function handle() + public function handle(): int { $name = $this->argument('name'); $stubOptions = $this->getStubOptions(); @@ -76,7 +75,7 @@ public function handle() } // Check if the file already exists - if (File::exists($path) && ! $this->option('force')) { + if (File::exists($path)) { $this->error($this->getTypeDescription()." {$className} already exists!"); return 1; @@ -93,10 +92,7 @@ public function handle() /** * Get a description of the type being created for display in messages. */ - protected function getTypeDescription(): string - { - return 'Class'; - } + abstract protected function getTypeDescription(): string; /** * Get the stub options based on command arguments and options. @@ -109,10 +105,7 @@ protected function getStubOptions(): array /** * Get the stub path based on the provided options. */ - protected function getStubPath(array $options): string - { - return $this->stubBasePath().'/default.stub'; - } + abstract protected function getStubPath(array $options): string; /** * Replace all variables in the stub file. @@ -137,7 +130,7 @@ protected function getPath(string $name): string { $name = Str::replaceFirst($this->rootNamespace(), '', $name); - return app_path(str_replace('\\', '/', $name).'.php'); + return app_path(str_replace('\\', DIRECTORY_SEPARATOR, $name).'.php'); } /** diff --git a/src/Conversion/DataConverter.php b/src/Conversion/DataConverter.php index ba0bad2..c5fb325 100644 --- a/src/Conversion/DataConverter.php +++ b/src/Conversion/DataConverter.php @@ -8,6 +8,12 @@ class DataConverter { + /** + * @template T of object + * + * @param class-string $className + * @return T + */ public static function fromArray(string $className, array $data): object { $metadata = FieldAnalyzer::analyzeClass($className); @@ -42,10 +48,6 @@ private static function getValueFromArray(array $data, FieldMetadata $field): mi return $data[$field->fieldName]; } - if ($field->allowsNull) { - return null; - } - if ($field->hasDefault) { return $field->defaultValue; } diff --git a/src/Traits/IsRunnable.php b/src/Traits/IsRunnable.php index e4660eb..f9c9cec 100644 --- a/src/Traits/IsRunnable.php +++ b/src/Traits/IsRunnable.php @@ -10,6 +10,6 @@ public static function run(...$params): mixed // to support dependency injection, then call the execute method $instance = app(static::class); - return $instance->handle(...$params) ?? null; + return $instance->handle(...$params); } } diff --git a/src/stubs/action.dispatchable.stub b/stubs/action.dispatchable.stub similarity index 100% rename from src/stubs/action.dispatchable.stub rename to stubs/action.dispatchable.stub diff --git a/src/stubs/action.invokable.stub b/stubs/action.invokable.stub similarity index 100% rename from src/stubs/action.invokable.stub rename to stubs/action.invokable.stub diff --git a/src/stubs/action.stub b/stubs/action.stub similarity index 100% rename from src/stubs/action.stub rename to stubs/action.stub diff --git a/src/stubs/dto.stub b/stubs/dto.stub similarity index 88% rename from src/stubs/dto.stub rename to stubs/dto.stub index e1f9962..206b44a 100644 --- a/src/stubs/dto.stub +++ b/stubs/dto.stub @@ -4,7 +4,7 @@ namespace {{ namespace }}; use LumoSolutions\Actionable\Traits\ArrayConvertible; -class {{ class }} +readonly class {{ class }} { use ArrayConvertible; diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..df2ced7 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in(__DIR__ . "/Unit/Console"); +uses(TestCase::class)->in(__DIR__); diff --git a/tests/Setup/Actions/DoDispatchableAction.php b/tests/Setup/Actions/DoDispatchableAction.php new file mode 100644 index 0000000..ac218f7 --- /dev/null +++ b/tests/Setup/Actions/DoDispatchableAction.php @@ -0,0 +1,15 @@ + 'value']); + + Queue::assertPushedOn('processing', AsyncActionJob::class); + }); + + it('dispatches correct number of jobs', function () { + $action = new class + { + use IsDispatchable; + + public function handle(): void {} + }; + + $className = get_class($action); + + $className::dispatch(); + $className::dispatch(); + $className::dispatchOn('test-queue'); + + Queue::assertPushed(AsyncActionJob::class, 3); + }); + + it('handles variadic parameters correctly', function () { + $action = new class + { + use IsDispatchable; + + public function handle(...$items): void + { + // Action logic here + } + }; + + $className = get_class($action); + + $className::dispatch('a', 'b', 'c', 'd'); + + Queue::assertPushed(AsyncActionJob::class); + }); + + it('can dispatch multiple different action classes', function () { + $action1 = new class + { + use IsDispatchable; + + public function handle(): void + { + // Action 1 logic + } + }; + + $action2 = new class + { + use IsDispatchable; + + public function handle(): void + { + // Action 2 logic + } + }; + + $class1 = get_class($action1); + $class2 = get_class($action2); + + $class1::dispatch(); + $class2::dispatch(); + + Queue::assertPushed(AsyncActionJob::class, 2); + }); + + it('maintains static context correctly', function () { + $action1 = new class + { + use IsDispatchable; + + public function handle(): void + { + // Action logic + } + }; + + $action2 = new class + { + use IsDispatchable; + + public function handle(): void + { + // Different action logic + } + }; + + $class1 = get_class($action1); + $class2 = get_class($action2); + + $class1::dispatch('param1'); + $class2::dispatch('param2'); + + Queue::assertPushed(AsyncActionJob::class, 2); + }); + + it('can dispatch with complex parameter types', function () { + $action = new class + { + use IsDispatchable; + + public function handle($string, $array, $object, $bool): void + { + // Action logic here + } + }; + + $className = get_class($action); + $testObject = (object) ['property' => 'value']; + + $className::dispatch( + 'test string', + ['array' => 'data'], + $testObject, + true + ); + + Queue::assertPushed(AsyncActionJob::class); + }); + + it('dispatch returns void', function () { + $action = new class + { + use IsDispatchable; + + public function handle(): void + { + // Action logic here + } + }; + + $className = get_class($action); + + $result = $className::dispatch(); + + expect($result)->toBeNull(); + }); + + it('dispatchOn returns void', function () { + $action = new class + { + use IsDispatchable; + + public function handle(): void + { + // Action logic here + } + }; + + $className = get_class($action); + + $result = $className::dispatchOn('test-queue'); + + expect($result)->toBeNull(); + }); + + it('executes the action handle method when job runs', function () { + $testContainer = new class + { + public bool $executed = false; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle(): void + { + $this->container->executed = true; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, []); + $job->handle(); + + expect($testContainer->executed)->toBeTrue(); + }); + + it('passes parameters to the action handle method', function () { + $testContainer = new class + { + public array $receivedParams = []; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle($param1, $param2): void + { + $this->container->receivedParams = [$param1, $param2]; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, ['hello', 'world']); + $job->handle(); + + expect($testContainer->receivedParams)->toBe(['hello', 'world']); + }); + + it('handles DoDispatchableAction execution', function () { + $job = new AsyncActionJob(DoDispatchableAction::class, []); + $result = $job->handle(); + + // The job handle method returns void, but we can verify it doesn't throw + expect($result)->toBeNull(); + }); + + it('executes action with complex parameters', function () { + $testContainer = new class + { + public $receivedData = null; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle($name, $data, $flag): void + { + $this->container->receivedData = compact('name', 'data', 'flag'); + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $testData = ['key' => 'value', 'items' => [1, 2, 3]]; + $job = new AsyncActionJob($className, ['test-name', $testData, true]); + $job->handle(); + + expect($testContainer->receivedData)->toBe([ + 'name' => 'test-name', + 'data' => $testData, + 'flag' => true, + ]); + }); + + it('executes action with variadic parameters', function () { + $testContainer = new class + { + public array $receivedItems = []; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle(...$items): void + { + $this->container->receivedItems = $items; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, ['a', 'b', 'c', 'd']); + $job->handle(); + + expect($testContainer->receivedItems)->toBe(['a', 'b', 'c', 'd']); + }); + + it('executes action that returns a value', function () { + $action = new class + { + use IsDispatchable; + + public function handle(): string + { + return 'executed successfully'; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, []); + + // Job handle method doesn't return the action result, but should not throw + $result = $job->handle(); + expect($result)->toBeNull(); + }); + + it('propagates exceptions from action handle method', function () { + $action = new class + { + use IsDispatchable; + + public function handle(): void + { + throw new \RuntimeException('Action failed'); + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, []); + + expect(fn () => $job->handle()) + ->toThrow(\RuntimeException::class, 'Action failed'); + }); + + it('creates action instance through Laravel container', function () { + $testContainer = new class + { + public bool $containerCalled = false; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle(): void + { + // Action logic + } + }; + + $className = get_class($action); + + // Mock the container call + app()->bind($className, function () use ($action, $testContainer) { + $testContainer->containerCalled = true; + + return $action; + }); + + $job = new AsyncActionJob($className, []); + $job->handle(); + + expect($testContainer->containerCalled)->toBeTrue(); + }); + + it('has correct display name', function () { + $job = new AsyncActionJob(DoDispatchableAction::class, []); + + expect($job->displayName())->toBe('Action: DoDispatchableAction'); + }); + + it('has correct tags', function () { + $job = new AsyncActionJob(DoDispatchableAction::class, []); + + expect($job->tags())->toBe([ + 'async_action', + 'DoDispatchableAction', + ]); + }); + + it('handles action with no parameters when empty array passed', function () { + $testContainer = new class + { + public bool $executed = false; + }; + + $action = new class($testContainer) + { + use IsDispatchable; + + private $container; + + public function __construct($container) + { + $this->container = $container; + } + + public function handle(): void + { + $this->container->executed = true; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $job = new AsyncActionJob($className, []); + $job->handle(); + + expect($testContainer->executed)->toBeTrue(); + }); +}); diff --git a/tests/Unit/Actions/InvokableActionTest.php b/tests/Unit/Actions/InvokableActionTest.php new file mode 100644 index 0000000..565b4ff --- /dev/null +++ b/tests/Unit/Actions/InvokableActionTest.php @@ -0,0 +1,290 @@ +toBe('Action invoked successfully'); + }); + + it('can be invoked with parameters', function () { + $action = new class + { + public function __invoke($param1, $param2): string + { + return "Params: {$param1}, {$param2}"; + } + }; + + $result = $action('hello', 'world'); + + expect($result)->toBe('Params: hello, world'); + }); + + it('can return different types', function () { + $stringAction = new class + { + public function __invoke(): string + { + return 'string result'; + } + }; + + $arrayAction = new class + { + public function __invoke(): array + { + return ['key' => 'value']; + } + }; + + $boolAction = new class + { + public function __invoke(): bool + { + return true; + } + }; + + $intAction = new class + { + public function __invoke(): int + { + return 42; + } + }; + + expect($stringAction())->toBe('string result') + ->and($arrayAction())->toBe(['key' => 'value']) + ->and($boolAction())->toBe(true) + ->and($intAction())->toBe(42); + }); + + it('can handle complex parameter combinations', function () { + $action = new class + { + public function __invoke(string $name, array $data = [], bool $flag = false): array + { + return [ + 'name' => $name, + 'data' => $data, + 'flag' => $flag, + ]; + } + }; + + $result = $action('test', ['item' => 'value'], true); + + expect($result)->toBe([ + 'name' => 'test', + 'data' => ['item' => 'value'], + 'flag' => true, + ]); + }); + + it('can handle variadic parameters', function () { + $action = new class + { + public function __invoke(...$items): array + { + return $items; + } + }; + + $result = $action('a', 'b', 'c', 'd'); + + expect($result)->toBe(['a', 'b', 'c', 'd']); + }); + + it('can be invoked through Laravel container', function () { + $action = new class + { + public function __invoke(): string + { + return 'Container invoked'; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $instance = app($className); + $result = $instance(); + + expect($result)->toBe('Container invoked'); + }); + + it('supports dependency injection', function () { + $dependency = new class + { + public function process(): string + { + return 'processed'; + } + }; + + $action = new class($dependency) + { + private $dependency; + + public function __construct($dependency) + { + $this->dependency = $dependency; + } + + public function __invoke(): string + { + return $this->dependency->process(); + } + }; + + $result = $action(); + + expect($result)->toBe('processed'); + }); + + it('can return null', function () { + $action = new class + { + public function __invoke(): null + { + return null; + } + }; + + $result = $action(); + + expect($result)->toBeNull(); + }); + + it('can handle exceptions', function () { + $action = new class + { + public function __invoke(): void + { + throw new \Exception('Something went wrong'); + } + }; + + expect(fn () => $action()) + ->toThrow(\Exception::class, 'Something went wrong'); + }); + + it('can be called with call_user_func', function () { + $action = new class + { + public function __invoke($param): string + { + return "Called with: {$param}"; + } + }; + + $result = call_user_func($action, 'test'); + + expect($result)->toBe('Called with: test'); + }); + + it('can be called with call_user_func_array', function () { + $action = new class + { + public function __invoke($param1, $param2): string + { + return "Called with: {$param1}, {$param2}"; + } + }; + + $result = call_user_func_array($action, ['hello', 'world']); + + expect($result)->toBe('Called with: hello, world'); + }); + + it('works with array_map', function () { + $action = new class + { + public function __invoke($item): string + { + return strtoupper($item); + } + }; + + $result = array_map($action, ['hello', 'world']); + + expect($result)->toBe(['HELLO', 'WORLD']); + }); + + it('can be used as a callback', function () { + $action = new class + { + public function __invoke($item): bool + { + return strlen($item) > 3; + } + }; + + $items = ['a', 'hello', 'hi', 'world']; + $result = array_filter($items, $action); + + expect(array_values($result))->toBe(['hello', 'world']); + }); + + it('maintains state between invocations', function () { + $action = new class + { + private int $counter = 0; + + public function __invoke(): int + { + return ++$this->counter; + } + }; + + expect($action())->toBe(1) + ->and($action())->toBe(2) + ->and($action())->toBe(3); + }); + + it('can handle complex return types', function () { + $action = new class + { + public function __invoke(): object + { + return (object) [ + 'message' => 'success', + 'data' => ['item1', 'item2'], + 'timestamp' => time(), + ]; + } + }; + + $result = $action(); + + expect($result)->toBeObject() + ->and($result->message)->toBe('success') + ->and($result->data)->toBe(['item1', 'item2']) + ->and($result->timestamp)->toBeInt(); + }); + + it('can access instance properties', function () { + $action = new class + { + private string $value = 'instance property'; + + public function __invoke(): string + { + return $this->value; + } + }; + + $result = $action(); + + expect($result)->toBe('instance property'); + }); +}); diff --git a/tests/Unit/Actions/RunnableActionTest.php b/tests/Unit/Actions/RunnableActionTest.php new file mode 100644 index 0000000..3086903 --- /dev/null +++ b/tests/Unit/Actions/RunnableActionTest.php @@ -0,0 +1,232 @@ +toBe('Action executed successfully'); + }); + + it('creates a new instance through Laravel container', function () { + $result = DoRunnableAction::run(); + + expect($result)->toBeString() + ->and($result)->not()->toBeEmpty(); + }); + + it('passes parameters to the handle method', function () { + $action = new class + { + use IsRunnable; + + public function handle($param1, $param2): string + { + return "Params: {$param1}, {$param2}"; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + $result = $className::run('hello', 'world'); + + expect($result)->toBe('Params: hello, world'); + }); + + it('handles actions with no parameters', function () { + $result = DoRunnableAction::run(); + + expect($result)->toBeString() + ->and($result)->not()->toBeEmpty(); + }); + + it('supports dependency injection through Laravel container', function () { + $dependency = new class + { + public function process(): string + { + return 'processed'; + } + }; + + $action = new class($dependency) + { + use IsRunnable; + + private $dependency; + + public function __construct($dependency) + { + $this->dependency = $dependency; + } + + public function handle(): string + { + return $this->dependency->process(); + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + $result = $className::run(); + + expect($result)->toBe('processed'); + }); + + it('returns mixed types from handle method', function () { + $stringAction = new class + { + use IsRunnable; + + public function handle(): string + { + return 'string result'; + } + }; + + $arrayAction = new class + { + use IsRunnable; + + public function handle(): array + { + return ['key' => 'value']; + } + }; + + $boolAction = new class + { + use IsRunnable; + + public function handle(): bool + { + return true; + } + }; + + app()->bind(get_class($stringAction), fn () => $stringAction); + app()->bind(get_class($arrayAction), fn () => $arrayAction); + app()->bind(get_class($boolAction), fn () => $boolAction); + + expect(get_class($stringAction)::run())->toBe('string result') + ->and(get_class($arrayAction)::run())->toBe(['key' => 'value']) + ->and(get_class($boolAction)::run())->toBe(true); + }); + + it('can handle complex parameter combinations', function () { + $action = new class + { + use IsRunnable; + + public function handle(string $name, array $data = [], bool $flag = false): array + { + return [ + 'name' => $name, + 'data' => $data, + 'flag' => $flag, + ]; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $result = $className::run('test', ['item' => 'value'], true); + + expect($result)->toBe([ + 'name' => 'test', + 'data' => ['item' => 'value'], + 'flag' => true, + ]); + }); + + it('handles exceptions thrown in handle method', function () { + $action = new class + { + use IsRunnable; + + public function handle(): void + { + throw new \Exception('Something went wrong'); + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + expect(fn () => $className::run()) + ->toThrow(\Exception::class, 'Something went wrong'); + }); + + it('maintains static context correctly', function () { + expect(DoRunnableAction::run())->toBe('Action executed successfully'); + + $action1 = new class + { + use IsRunnable; + + public function handle(): string + { + return 'action1'; + } + }; + + $action2 = new class + { + use IsRunnable; + + public function handle(): string + { + return 'action2'; + } + }; + + $class1 = get_class($action1); + $class2 = get_class($action2); + + app()->bind($class1, fn () => $action1); + app()->bind($class2, fn () => $action2); + + expect($class1::run())->toBe('action1') + ->and($class2::run())->toBe('action2'); + }); + + it('can handle null return values', function () { + $action = new class + { + use IsRunnable; + + public function handle(): null + { + return null; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + + $result = $className::run(); + + expect($result)->toBeNull(); + }); + + it('works with variadic parameters', function () { + $action = new class + { + use IsRunnable; + + public function handle(...$items): array + { + return $items; + } + }; + + $className = get_class($action); + app()->bind($className, fn () => $action); + $result = $className::run('a', 'b', 'c', 'd'); + + expect($result)->toBe(['a', 'b', 'c', 'd']); + }); +}); diff --git a/tests/Unit/ArchTest.php b/tests/Unit/ArchTest.php new file mode 100644 index 0000000..c92d12a --- /dev/null +++ b/tests/Unit/ArchTest.php @@ -0,0 +1,35 @@ +expect("LumoSolutions\Actionable\ActionableProvider") + ->toExtend('Illuminate\Support\ServiceProvider'); + + arch('it will not use debugging functions') + ->expect("LumoSolutions\Actionable") + ->not->toUse(['die', 'dd', 'dump', 'ray']); + + arch('it will not use super globals') + ->expect("LumoSolutions\Actionable") + ->not->toUse(['$_GET', '$_POST', '$_SESSION', '$_COOKIE', '$_SERVER']); + + arch('traits should have trait declaration') + ->expect("LumoSolutions\Actionable\Traits") + ->toBeTrait(); + + arch('commands should extend artisan command') + ->expect("LumoSolutions\Actionable\Console\Commands") + ->toExtend('Illuminate\Console\Command'); + + arch('commands should have proper naming') + ->expect("LumoSolutions\Actionable\Console\Commands") + ->toHaveSuffix('Command'); + + arch('attributes should have attribute declaration') + ->expect("LumoSolutions\Actionable\Attributes") + ->toHaveAttribute('Attribute'); + + arch('attributes should be read only') + ->expect("LumoSolutions\Actionable\Attributes") + ->toBeReadonly(); +}); diff --git a/tests/Unit/Console/Configuration/stubs/action.dispatchable.stub b/tests/Unit/Console/Configuration/stubs/action.dispatchable.stub new file mode 100644 index 0000000..f921b3d --- /dev/null +++ b/tests/Unit/Console/Configuration/stubs/action.dispatchable.stub @@ -0,0 +1,16 @@ +copyStubs(); + $this->artisan(MakeActionCommand::class, ['name' => 'CustomAction']) + ->assertExitCode(0); + + $path = app_path('/Actions/CustomAction.php'); + $content = File::get($path); + + // Should contain custom content from application stub + expect($content)->toContain('// This is a custom application stub'); + }); + + it('falls back to package stub when application stub does not exist', function () { + // Test with a stub that only exists in package fixtures + $this->artisan(MakeActionCommand::class, ['name' => 'StandardAction']) + ->assertExitCode(0); + + $path = app_path('Actions/StandardAction.php'); + $content = File::get($path); + + // Should contain standard package stub content + expect($content)->toContain('use LumoSolutions\Actionable\Traits\IsRunnable;') + ->and($content)->not->toContain('custom application stub'); + }); + + it('creates a basic action file successfully', function () { + $this->artisan(MakeActionCommand::class, ['name' => 'TestAction']) + ->assertExitCode(0) + ->expectsOutput('Action App\Actions\TestAction created successfully.'); + + $expectedPath = app_path('Actions/TestAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions;') + ->and($content)->toContain('class TestAction') + ->and($content)->toContain('use LumoSolutions\Actionable\Traits\IsRunnable;') + ->and($content)->toContain('public function handle(): void'); + }); + + it('creates an invokable action file', function () { + $this->artisan(MakeActionCommand::class, [ + 'name' => 'InvokableAction', + '--invokable' => true, + ]) + ->assertExitCode(0) + ->expectsOutput('Action App\Actions\InvokableAction created successfully.'); + + $expectedPath = app_path('Actions/InvokableAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions;') + ->and($content)->toContain('class InvokableAction') + ->and($content)->toContain('public function __invoke(): mixed') + ->and($content)->not->toContain('IsRunnable'); + }); + + it('creates a dispatchable action file', function () { + $this->artisan(MakeActionCommand::class, [ + 'name' => 'DispatchableAction', + '--dispatchable' => true, + ]) + ->assertExitCode(0) + ->expectsOutput('Action App\Actions\DispatchableAction created successfully.'); + + $expectedPath = app_path('Actions/DispatchableAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions;') + ->and($content)->toContain('class DispatchableAction') + ->and($content)->toContain('use LumoSolutions\Actionable\Traits\IsRunnable;') + ->and($content)->toContain('use LumoSolutions\Actionable\Traits\IsDispatchable;') + ->and($content)->toContain('public function handle(): void'); + }); + + it('creates nested namespace actions', function () { + $this->artisan(MakeActionCommand::class, ['name' => 'User/CreateUserAction']) + ->assertExitCode(0); + + $expectedPath = app_path('Actions/User/CreateUserAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions\User;') + ->and($content)->toContain('class CreateUserAction'); + }); + + it('creates directories when they do not exist', function () { + $nestedPath = app_path('/Actions/Deep/Nested'); + expect(File::isDirectory($nestedPath))->toBeFalse(); + + $this->artisan(MakeActionCommand::class, ['name' => 'Deep\\Nested\\DeepAction']) + ->assertExitCode(0) + ->expectsOutput('Action App\Actions\Deep\Nested\DeepAction created successfully.'); + + expect(File::isDirectory($nestedPath))->toBeTrue(); + + $expectedPath = $nestedPath.'/DeepAction.php'; + expect(File::exists($expectedPath))->toBeTrue(); + }); + + it('selects correct stub based on invokable option', function () { + $this->artisan(MakeActionCommand::class, [ + 'name' => 'InvokableTest', + '--invokable' => true, + ])->assertExitCode(0); + + $path = app_path('Actions/InvokableTest.php'); + $content = File::get($path); + + expect($content)->toContain('public function __invoke(): mixed') + ->and($content)->not->toContain('public function handle(): void'); + }); + + it('selects correct stub based on dispatchable option', function () { + $this->artisan(MakeActionCommand::class, [ + 'name' => 'DispatchableTest', + '--dispatchable' => true, + ])->assertExitCode(0); + + $path = app_path('Actions/DispatchableTest.php'); + $content = File::get($path); + + expect($content)->toContain('use LumoSolutions\Actionable\Traits\IsDispatchable;') + ->and($content)->toContain('public function handle(): void'); + }); + + it('prioritizes dispatchable over invokable when both flags are set', function () { + $this->artisan(MakeActionCommand::class, [ + 'name' => 'PriorityTest', + '--invokable' => true, + '--dispatchable' => true, + ])->assertExitCode(0); + + $path = app_path('Actions/PriorityTest.php'); + $content = File::get($path); + + // Should use dispatchable stub, not invokable + expect($content)->toContain('use LumoSolutions\Actionable\Traits\IsDispatchable;') + ->and($content)->toContain('public function handle(): void') + ->and($content)->not->toContain('public function __invoke(): mixed'); + }); + + it('handles class names with leading slashes', function () { + $this->artisan(MakeActionCommand::class, ['name' => '/LeadingSlashAction']) + ->assertExitCode(0); + + $expectedPath = app_path('Actions/LeadingSlashAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions;') + ->and($content)->toContain('class LeadingSlashAction'); + }); + + it('converts forward slashes to backslashes in namespaces', function () { + $this->artisan(MakeActionCommand::class, ['name' => 'Admin/User/AdminUserAction']) + ->assertExitCode(0); + + $expectedPath = app_path('Actions/Admin/User/AdminUserAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions\Admin\User;') + ->and($content)->toContain('class AdminUserAction'); + }); + + it('handles already qualified class names', function () { + $this->artisan(MakeActionCommand::class, ['name' => 'App\Actions\QualifiedAction']) + ->assertExitCode(0); + + $expectedPath = app_path('Actions/QualifiedAction.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Actions;') + ->and($content)->toContain('class QualifiedAction'); + }); + + it('throws an error where the action already exists', function () { + $this->artisan(MakeActionCommand::class, ['name' => 'ExistingAction']) + ->assertExitCode(0); + + // Try to create the same action again + $this->artisan(MakeActionCommand::class, ['name' => 'ExistingAction']) + ->assertExitCode(1) + ->expectsOutput('Action App\Actions\ExistingAction already exists!'); + }); + }); +}); diff --git a/tests/Unit/Console/MakeDtoCommandTest.php b/tests/Unit/Console/MakeDtoCommandTest.php new file mode 100644 index 0000000..d8c14cd --- /dev/null +++ b/tests/Unit/Console/MakeDtoCommandTest.php @@ -0,0 +1,24 @@ +artisan(MakeDtoCommand::class, ['name' => 'ExampleDto']) + ->assertExitCode(0) + ->expectsOutput('DTO App\Dtos\ExampleDto created successfully.'); + + $expectedPath = app_path('Dtos/ExampleDto.php'); + expect(File::exists($expectedPath))->toBeTrue(); + + $content = File::get($expectedPath); + expect($content)->toContain('namespace App\Dtos;') + ->and($content)->toContain('class ExampleDto') + ->and($content)->toContain('use LumoSolutions\Actionable\Traits\ArrayConvertible;') + ->and($content)->toContain('public function __construct(') + ->and($content)->toContain('// public string $property,'); + }); + }); +}); diff --git a/tests/Unit/Dtos/ArrayOfAttributeTest.php b/tests/Unit/Dtos/ArrayOfAttributeTest.php new file mode 100644 index 0000000..64a4f10 --- /dev/null +++ b/tests/Unit/Dtos/ArrayOfAttributeTest.php @@ -0,0 +1,44 @@ +items)->toHaveCount(2) + ->and($dto->items)->toBeArray(ItemDto::class) + ->and($dto->items[0])->toBeInstanceOf(ItemDto::class); + }); + + it('can be output as an array', function () { + $dto = new ArrayOfDto( + [new ItemDto('1'), new ItemDto('2')] + ); + + expect($dto->toArray())->toHaveKey('items') + ->and($dto->toArray()['items'])->toHaveCount(2) + ->and($dto->toArray()['items'][0])->toHaveKey('name') + ->and($dto->toArray()['items'][0]['name'])->toBe('1'); + }); + + it('can be built from an array', function () { + $array = [ + 'items' => [ + ['name' => '1'], + ['name' => '2'], + ], + ]; + + $dto = ArrayOfDto::fromArray($array); + + expect($dto->items)->toHaveCount(2) + ->and($dto->items)->toBeArray(ItemDto::class) + ->and($dto->items[0])->toBeInstanceOf(ItemDto::class); + }); + }); +}); diff --git a/tests/Unit/Dtos/DateFormatAttributeTest.php b/tests/Unit/Dtos/DateFormatAttributeTest.php new file mode 100644 index 0000000..bb23aa9 --- /dev/null +++ b/tests/Unit/Dtos/DateFormatAttributeTest.php @@ -0,0 +1,80 @@ +eventDate)->toBeInstanceOf(DateTime::class) + ->and($dto->createdAt)->toBeInstanceOf(DateTime::class) + ->and($dto->updatedAt)->toBeInstanceOf(DateTime::class); + }); + + it('can be output to array with formatted dates', function () { + $dto = new DateFormatDto( + eventDate: new DateTime('2024-01-15'), + createdAt: new DateTime('2024-01-15 14:30:00'), + updatedAt: new DateTime('2024-01-15 14:30:00') + ); + + $array = $dto->toArray(); + + expect($array)->toHaveKey('eventDate') + ->and($array['eventDate'])->toBe('2024-01-15') + ->and($array)->toHaveKey('createdAt') + ->and($array['createdAt'])->toBe('15/01/2024 14:30') + ->and($array)->toHaveKey('updatedAt') + ->and($array['updatedAt'])->toBe('2024-01-15 14:30:00'); + }); + + it('can be built from array with different date formats', function () { + $dto = DateFormatDto::fromArray([ + 'eventDate' => '2024-12-25', + 'createdAt' => '25/12/2024 09:15', + 'updatedAt' => '2024-12-25 09:15:30', + ]); + + expect($dto->eventDate)->toBeInstanceOf(DateTime::class) + ->and($dto->eventDate->format('Y-m-d'))->toBe('2024-12-25') + ->and($dto->createdAt)->toBeInstanceOf(DateTime::class) + ->and($dto->createdAt->format('d/m/Y H:i'))->toBe('25/12/2024 09:15') + ->and($dto->updatedAt)->toBeInstanceOf(DateTime::class) + ->and($dto->updatedAt->format('Y-m-d H:i:s'))->toBe('2024-12-25 09:15:30'); + }); + + it('handles default date format when no format specified', function () { + $dto = new DateFormatDto( + eventDate: new DateTime('2024-01-01'), + createdAt: new DateTime('2024-01-01 00:00:00'), + updatedAt: new DateTime('2024-01-01 00:00:00') + ); + + expect($dto->updatedAt)->toBeInstanceOf(DateTime::class) + ->and($dto->updatedAt->format('Y-m-d H:i:s'))->toBe('2024-01-01 00:00:00'); + }); + + it('can convert between different date formats via array conversion', function () { + $originalDto = new DateFormatDto( + eventDate: new DateTime('2024-06-15'), + createdAt: new DateTime('2024-06-15 12:45:00'), + updatedAt: new DateTime('2024-06-15 12:45:30') + ); + + $array = $originalDto->toArray(); + $newDto = DateFormatDto::fromArray($array); + + expect($newDto->eventDate)->toBeInstanceOf(DateTime::class) + ->and($newDto->eventDate->format('Y-m-d'))->toBe($originalDto->eventDate->format('Y-m-d')) + ->and($newDto->createdAt)->toBeInstanceOf(DateTime::class) + ->and($newDto->createdAt->format('d/m/Y H:i'))->toBe($originalDto->createdAt->format('d/m/Y H:i')) + ->and($newDto->updatedAt)->toBeInstanceOf(DateTime::class) + ->and($newDto->updatedAt->format('Y-m-d H:i:s'))->toBe($originalDto->updatedAt->format('Y-m-d H:i:s')); + }); + }); +}); diff --git a/tests/Unit/Dtos/FieldNameAttributeTest.php b/tests/Unit/Dtos/FieldNameAttributeTest.php new file mode 100644 index 0000000..fdae771 --- /dev/null +++ b/tests/Unit/Dtos/FieldNameAttributeTest.php @@ -0,0 +1,46 @@ +name)->toBe('company'); + }); + + it('can be output to an array with correct keys', function () { + $dto = new FieldNameDto(name: 'company', description: 'description'); + + expect($dto->toArray())->toHaveKey('company_name') + ->and($dto->toArray()['company_name'])->toEqual('company') + ->and($dto->toArray())->not()->toHaveKey('name'); + }); + + it('can be built from an array', function () { + $dto = FieldNameDto::fromArray(['company_name' => 'company']); + expect($dto->name)->toBe('company') + ->and($dto->toArray())->toHaveKey('company_name') + ->and($dto->toArray())->not()->toHaveKey('name'); + }); + + it('gets a default value where not set on construction', function () { + $dto = new FieldNameDto(name: 'company', description: 'description'); + + expect($dto->default)->toBe('default_value'); + }); + + it('gets a default value where not set fromArray', function () { + $dto = FieldNameDto::fromArray(['company_name' => 'company']); + + expect($dto->default)->toBe('default_value'); + }); + + it('sets a null value for a nullable field, where not provided', function () { + $dto = FieldNameDto::fromArray(['company_name' => 'company']); + + expect($dto->description)->toBeNull(); + }); + }); +}); diff --git a/tests/Unit/Dtos/IgnoreAttributeTest.php b/tests/Unit/Dtos/IgnoreAttributeTest.php new file mode 100644 index 0000000..0a40830 --- /dev/null +++ b/tests/Unit/Dtos/IgnoreAttributeTest.php @@ -0,0 +1,30 @@ +name)->toBe('company') + ->and($dto->secret)->toBe('api_key'); + }); + + it('can be output as an array', function () { + $dto = new IgnoreDto(name: 'company', secret: 'api_key'); + + expect($dto->toArray())->toHaveKey('name') + ->and($dto->toArray())->not()->toHaveKey('secret'); + }); + + it('can be built from an array', function () { + $dto = IgnoreDto::fromArray([ + 'name' => 'company', 'secret' => 'api_key', + ]); + + expect($dto->name)->toBe('company') + ->and($dto->secret)->toBe('api_key'); + }); + }); +}); diff --git a/tests/Unit/Dtos/NestedClassTest.php b/tests/Unit/Dtos/NestedClassTest.php new file mode 100644 index 0000000..23650cf --- /dev/null +++ b/tests/Unit/Dtos/NestedClassTest.php @@ -0,0 +1,37 @@ +toArray())->toBe([ + 'name' => 'child', + 'parent' => [ + 'name' => 'parent', + 'parent' => null, + ], + ]); + }); + + it('converts a nested class from array', function () { + $data = [ + 'name' => 'child', + 'parent' => [ + 'name' => 'parent', + 'parent' => null, + ], + ]; + + $child = ItemDto::fromArray($data); + + expect($child->name)->toBe('child') + ->and($child->parent)->toBeInstanceOf(ItemDto::class) + ->and($child->parent->name)->toBe('parent') + ->and($child->parent->parent)->toBeNull(); + }); + }); +});