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();
+ });
+ });
+});