diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index bcdbd58..e6ca211 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -5,18 +5,10 @@
"service": "app",
"shutdownAction": "stopCompose",
"extensions": [
- "editorconfig.editorconfig",
- "ryannaddy.laravel-artisan",
- "amiralizadeh9480.laravel-extra-intellisense",
- "stef-k.laravel-goto-controller",
- "codingyu.laravel-goto-view",
- "mikestead.dotenv",
- "christian-kohler.path-intellisense",
- "esbenp.prettier-vscode",
- "CoenraadS.bracket-pair-colorizer"
+ "editorconfig.editorconfig",
],
"settings": {
"#terminal.integrated.shell.linux": "/bin/bash"
},
- "postCreateCommand": "cp .env.example .env && composer install",
+ "postCreateCommand": "composer install",
}
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 8360a6c..6874790 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -34,7 +34,7 @@ jobs:
- 6379:6379
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
with:
fetch-depth: 10
@@ -43,7 +43,7 @@ jobs:
- name: Cache Composer packages
id: composer-cache
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
@@ -57,7 +57,7 @@ jobs:
run: vendor/bin/ecs check
- name: Run static analysis via PHPStan
- run: vendor/bin/phpstan --xdebug analyse src tests
+ run: vendor/bin/phpstan analyse src tests
- name: Create databases
run: |
diff --git a/.gitignore b/.gitignore
index 50e6f7f..271eb9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ composer.lock
vendor
coverage
.env
+.phpunit.cache
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
diff --git a/README.md b/README.md
index e3dd500..95023e0 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
-
+
Laravel Workflow is a package for the Laravel web framework that provides tools for defining and managing workflows and activities. A workflow is a series of interconnected activities that are executed in a specific order to achieve a desired result. Activities are individual tasks or pieces of logic that are executed as part of a workflow.
-Laravel Workflow can be used to automate and manage complex processes, such as financial transactions, data analysis, data pipelines, microservices, job tracking, user signup flows, sagas and other business processes. By using Laravel Workflow, developers can break down large, complex processes into smaller, modular units that can be easily maintained and updated.
+Laravel Workflow can be used to automate and manage complex processes, such as agentic workflows (AI-driven), financial transactions, data analysis, data pipelines, microservices, job tracking, user signup flows, sagas and other business processes. By using Laravel Workflow, developers can break down large, complex processes into smaller, modular units that can be easily maintained and updated.
Some key features and benefits of Laravel Workflow include:
diff --git a/artisan b/artisan
deleted file mode 100755
index f85e657..0000000
--- a/artisan
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env php
-safeLoad();
-
-$app = require_once __DIR__.'/vendor/orchestra/testbench-core/laravel/bootstrap/app.php';
-
-/*
-|--------------------------------------------------------------------------
-| Run The Artisan Application
-|--------------------------------------------------------------------------
-|
-| When we run the console application, the current CLI command will be
-| executed in this console and the response sent back to a terminal
-| or another output device for the developers. Here goes nothing!
-|
-*/
-
-$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
-
-$status = $kernel->handle(
- $input = new Symfony\Component\Console\Input\ArgvInput,
- new Symfony\Component\Console\Output\ConsoleOutput
-);
-
-/*
-|--------------------------------------------------------------------------
-| Shutdown The Application
-|--------------------------------------------------------------------------
-|
-| Once Artisan has finished running, we will fire off the shutdown events
-| so that any final work may be done by the application before we shut
-| down the process. This is the last thing to happen to the request.
-|
-*/
-
-$kernel->terminate($input, $status);
-
-exit($status);
diff --git a/composer.json b/composer.json
index adde37b..084fc66 100644
--- a/composer.json
+++ b/composer.json
@@ -10,16 +10,33 @@
},
"autoload-dev": {
"psr-4": {
- "Tests\\": "tests/"
+ "Tests\\": "tests/",
+ "Workbench\\App\\": "workbench/app/",
+ "Workbench\\Database\\Factories\\": "workbench/database/factories/",
+ "Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
}
},
"scripts": {
- "rector": "vendor/bin/rector",
"ecs": "vendor/bin/ecs check --fix",
"stan": "vendor/bin/phpstan analyse src tests",
"feature": "phpunit --testdox --testsuite feature",
"unit": "phpunit --testdox --testsuite unit",
- "test": "phpunit --testdox"
+ "test": "phpunit --testdox",
+ "post-autoload-dump": [
+ "@clear",
+ "@prepare"
+ ],
+ "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
+ "prepare": "@php vendor/bin/testbench package:discover --ansi",
+ "build": "@php vendor/bin/testbench workbench:build --ansi",
+ "serve": [
+ "Composer\\Config::disableProcessTimeout",
+ "@build",
+ "@php vendor/bin/testbench serve --ansi"
+ ],
+ "lint": [
+ "@php vendor/bin/phpstan analyse --verbose --ansi"
+ ]
},
"authors": [
{
@@ -28,17 +45,16 @@
}
],
"require": {
- "php": "^8.0.2",
+ "php": "^8.1",
"laravel/framework": "^9.0|^10.0|^11.0|^12.0",
- "spatie/laravel-model-states": "^2.1",
+ "spatie/laravel-model-states": "^2.0",
"react/promise": "^2.9|^3.0"
},
"require-dev": {
- "orchestra/testbench": "^7.1",
- "phpstan/phpstan": "^1.9",
- "rector/rector": "^0.15.1",
+ "orchestra/testbench": "^8.0",
+ "phpstan/phpstan": "^2.0",
"scrutinizer/ocular": "dev-master",
- "symplify/easy-coding-standard": "^11.1"
+ "symplify/easy-coding-standard": "^11.0"
},
"extra": {
"laravel": {
diff --git a/phpstan.neon b/phpstan.neon
index 78d0039..ed7823c 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,4 +1,4 @@
parameters:
level: 0
bootstrapFiles:
- - classAliases.php
+ - tests/classAliases.php
diff --git a/phpunit.xml b/phpunit.xml
index 190df25..406e361 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,10 +1,5 @@
-
-
-
- src/
-
-
+
./tests/Feature
@@ -16,4 +11,9 @@
+
+
+ src/
+
+
diff --git a/rector.php b/rector.php
deleted file mode 100644
index 8cdce60..0000000
--- a/rector.php
+++ /dev/null
@@ -1,30 +0,0 @@
-paths([
- __DIR__ . '/src',
- __DIR__ . '/tests'
- ]);
-
- $rectorConfig->parallel(240);
-
- $rectorConfig->phpVersion(PhpVersion::PHP_81);
-
- $rectorConfig->sets([
- LevelSetList::UP_TO_PHP_81,
- SetList::CODE_QUALITY,
- SetList::DEAD_CODE,
- SetList::PRIVATIZATION,
- SetList::NAMING,
- SetList::TYPE_DECLARATION,
- SetList::EARLY_RETURN,
- SetList::CODING_STYLE,
- ]);
-};
diff --git a/src/Auth/NullAuthenticator.php b/src/Auth/NullAuthenticator.php
new file mode 100644
index 0000000..ed0b086
--- /dev/null
+++ b/src/Auth/NullAuthenticator.php
@@ -0,0 +1,15 @@
+header(config('workflows.webhook_auth.signature.header')),
+ (string) hash_hmac('sha256', $request->getContent(), config('workflows.webhook_auth.signature.secret'))
+ )) {
+ abort(401, 'Unauthorized');
+ }
+ return $request;
+ }
+}
diff --git a/src/Auth/TokenAuthenticator.php b/src/Auth/TokenAuthenticator.php
new file mode 100644
index 0000000..caa50a0
--- /dev/null
+++ b/src/Auth/TokenAuthenticator.php
@@ -0,0 +1,21 @@
+header(config('workflows.webhook_auth.token.header'))
+ )) {
+ abort(401, 'Unauthorized');
+ }
+ return $request;
+ }
+}
diff --git a/src/Auth/WebhookAuthenticator.php b/src/Auth/WebhookAuthenticator.php
new file mode 100644
index 0000000..84051a7
--- /dev/null
+++ b/src/Auth/WebhookAuthenticator.php
@@ -0,0 +1,12 @@
+auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->withOptions([
- 'query' => [
- 'id' => 'eq.' . $event->activityId,
- ],
- ])
- ->patch(config('workflows.monitor_url') . '/rest/v1/activities', [
- 'output' => $event->output,
- 'status' => 'completed',
- 'updated_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Listeners/MonitorActivityFailed.php b/src/Listeners/MonitorActivityFailed.php
deleted file mode 100644
index e1d331c..0000000
--- a/src/Listeners/MonitorActivityFailed.php
+++ /dev/null
@@ -1,37 +0,0 @@
-auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->withOptions([
- 'query' => [
- 'id' => 'eq.' . $event->activityId,
- ],
- ])
- ->patch(config('workflows.monitor_url') . '/rest/v1/activities', [
- 'output' => $event->output,
- 'status' => 'failed',
- 'updated_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Listeners/MonitorActivityStarted.php b/src/Listeners/MonitorActivityStarted.php
deleted file mode 100644
index 60314c1..0000000
--- a/src/Listeners/MonitorActivityStarted.php
+++ /dev/null
@@ -1,37 +0,0 @@
-auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->post(config('workflows.monitor_url') . '/rest/v1/activities', [
- 'id' => $event->activityId,
- 'user_id' => $auth['user'],
- 'workflow_id' => $event->workflowId,
- 'class' => $event->class,
- 'index' => $event->index,
- 'arguments' => $event->arguments,
- 'status' => 'running',
- 'created_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Listeners/MonitorWorkflowCompleted.php b/src/Listeners/MonitorWorkflowCompleted.php
deleted file mode 100644
index e9e77c8..0000000
--- a/src/Listeners/MonitorWorkflowCompleted.php
+++ /dev/null
@@ -1,38 +0,0 @@
-auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->withOptions([
- 'query' => [
- 'user_id' => 'eq.' . $auth['user'],
- 'workflow_id' => 'eq.' . $event->workflowId,
- ],
- ])
- ->patch(config('workflows.monitor_url') . '/rest/v1/workflows', [
- 'output' => $event->output,
- 'status' => 'completed',
- 'updated_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Listeners/MonitorWorkflowFailed.php b/src/Listeners/MonitorWorkflowFailed.php
deleted file mode 100644
index 62d6422..0000000
--- a/src/Listeners/MonitorWorkflowFailed.php
+++ /dev/null
@@ -1,38 +0,0 @@
-auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->withOptions([
- 'query' => [
- 'user_id' => 'eq.' . $auth['user'],
- 'workflow_id' => 'eq.' . $event->workflowId,
- ],
- ])
- ->patch(config('workflows.monitor_url') . '/rest/v1/workflows', [
- 'output' => $event->output,
- 'status' => 'failed',
- 'updated_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Listeners/MonitorWorkflowStarted.php b/src/Listeners/MonitorWorkflowStarted.php
deleted file mode 100644
index 7e6851d..0000000
--- a/src/Listeners/MonitorWorkflowStarted.php
+++ /dev/null
@@ -1,35 +0,0 @@
-auth();
-
- Http::withToken($auth['token'])
- ->withHeaders([
- 'apiKey' => $auth['public'],
- ])
- ->post(config('workflows.monitor_url') . '/rest/v1/workflows', [
- 'user_id' => $auth['user'],
- 'workflow_id' => $event->workflowId,
- 'class' => $event->class,
- 'arguments' => $event->arguments,
- 'status' => 'running',
- 'created_at' => $event->timestamp,
- ]);
- }
-}
diff --git a/src/Middleware/ActivityMiddleware.php b/src/Middleware/ActivityMiddleware.php
index f769722..3f6ea4f 100644
--- a/src/Middleware/ActivityMiddleware.php
+++ b/src/Middleware/ActivityMiddleware.php
@@ -41,6 +41,9 @@ public function handle($job, $next): void
now()
->format('Y-m-d\TH:i:s.u\Z')
);
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $throwable) {
+ $job->storedWorkflow->toWorkflow()
+ ->fail($throwable);
} catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) {
if ($job->storedWorkflow->toWorkflow()->running()) {
$job->release();
diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php
index ba91d9d..945e1a1 100644
--- a/src/Providers/WorkflowServiceProvider.php
+++ b/src/Providers/WorkflowServiceProvider.php
@@ -5,23 +5,10 @@
namespace Workflow\Providers;
use Illuminate\Database\Eloquent\Model;
-use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Laravel\SerializableClosure\SerializableClosure;
use Workflow\Commands\ActivityMakeCommand;
use Workflow\Commands\WorkflowMakeCommand;
-use Workflow\Events\ActivityCompleted;
-use Workflow\Events\ActivityFailed;
-use Workflow\Events\ActivityStarted;
-use Workflow\Events\WorkflowCompleted;
-use Workflow\Events\WorkflowFailed;
-use Workflow\Events\WorkflowStarted;
-use Workflow\Listeners\MonitorActivityCompleted;
-use Workflow\Listeners\MonitorActivityFailed;
-use Workflow\Listeners\MonitorActivityStarted;
-use Workflow\Listeners\MonitorWorkflowCompleted;
-use Workflow\Listeners\MonitorWorkflowFailed;
-use Workflow\Listeners\MonitorWorkflowStarted;
final class WorkflowServiceProvider extends ServiceProvider
{
@@ -33,15 +20,6 @@ class_alias(config('workflows.base_model', Model::class), 'Workflow\Models\Model
SerializableClosure::setSecretKey(config('app.key'));
- if (config('workflows.monitor', false)) {
- Event::listen(WorkflowStarted::class, [MonitorWorkflowStarted::class, 'handle']);
- Event::listen(WorkflowCompleted::class, [MonitorWorkflowCompleted::class, 'handle']);
- Event::listen(WorkflowFailed::class, [MonitorWorkflowFailed::class, 'handle']);
- Event::listen(ActivityStarted::class, [MonitorActivityStarted::class, 'handle']);
- Event::listen(ActivityCompleted::class, [MonitorActivityCompleted::class, 'handle']);
- Event::listen(ActivityFailed::class, [MonitorActivityFailed::class, 'handle']);
- }
-
$this->publishes([
__DIR__ . '/../config/workflows.php' => config_path('workflows.php'),
], 'config');
@@ -52,9 +30,4 @@ class_alias(config('workflows.base_model', Model::class), 'Workflow\Models\Model
$this->commands([ActivityMakeCommand::class, WorkflowMakeCommand::class]);
}
-
- public function register(): void
- {
- //
- }
}
diff --git a/src/Serializers/Serializer.php b/src/Serializers/Serializer.php
index 07b9338..8df8178 100644
--- a/src/Serializers/Serializer.php
+++ b/src/Serializers/Serializer.php
@@ -15,7 +15,7 @@ public static function __callStatic(string $name, array $arguments)
$instance = Y::getInstance();
}
} else {
- $instance = config('serializer', Y::class)::getInstance();
+ $instance = config('workflows.serializer', Y::class)::getInstance();
}
if (method_exists($instance, $name)) {
diff --git a/src/Traits/FetchesMonitorAuth.php b/src/Traits/FetchesMonitorAuth.php
deleted file mode 100644
index e06546e..0000000
--- a/src/Traits/FetchesMonitorAuth.php
+++ /dev/null
@@ -1,20 +0,0 @@
-get(config('workflows.monitor_url') . '/functions/v1/get-user')
- ->json();
- });
- }
-}
diff --git a/src/Traits/MonitorQueueConnection.php b/src/Traits/MonitorQueueConnection.php
deleted file mode 100644
index 9bbcc76..0000000
--- a/src/Traits/MonitorQueueConnection.php
+++ /dev/null
@@ -1,21 +0,0 @@
-viaConnection() . '.queue', 'default')
- );
- }
-}
diff --git a/src/Traits/Timers.php b/src/Traits/Timers.php
index 88fa010..2b2a324 100644
--- a/src/Traits/Timers.php
+++ b/src/Traits/Timers.php
@@ -5,7 +5,6 @@
namespace Workflow\Traits;
use Carbon\CarbonInterval;
-use Illuminate\Database\QueryException;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use function React\Promise\resolve;
@@ -64,7 +63,7 @@ public static function timer($seconds): PromiseInterface
'class' => Signal::class,
'result' => Serializer::serialize(true),
]);
- } catch (QueryException $exception) {
+ } catch (\Illuminate\Database\UniqueConstraintViolationException $exception) {
// already logged
}
}
diff --git a/src/Webhooks.php b/src/Webhooks.php
index 559ac94..36220f2 100644
--- a/src/Webhooks.php
+++ b/src/Webhooks.php
@@ -9,6 +9,10 @@
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionMethod;
+use Workflow\Auth\NullAuthenticator;
+use Workflow\Auth\SignatureAuthenticator;
+use Workflow\Auth\TokenAuthenticator;
+use Workflow\Auth\WebhookAuthenticator;
class Webhooks
{
@@ -75,12 +79,7 @@ private static function registerWorkflowWebhooks($workflow, $basePath)
if ($method->getName() === 'execute') {
$slug = Str::kebab(class_basename($workflow));
Route::post("{$basePath}/start/{$slug}", static function (Request $request) use ($workflow) {
- if (! self::validateAuth($request)) {
- return response()->json([
- 'error' => 'Unauthorized',
- ], 401);
- }
-
+ $request = self::validateAuth($request);
$params = self::resolveNamedParameters($workflow, 'execute', $request->all());
WorkflowStub::make($workflow)->start(...$params);
return response()->json([
@@ -97,24 +96,17 @@ private static function registerSignalWebhooks($workflow, $basePath)
if (self::hasWebhookAttributeOnMethod($method)) {
$slug = Str::kebab(class_basename($workflow));
$signal = Str::kebab($method->getName());
-
Route::post(
"{$basePath}/signal/{$slug}/{workflowId}/{$signal}",
static function (Request $request, $workflowId) use ($workflow, $method) {
- if (! self::validateAuth($request)) {
- return response()->json([
- 'error' => 'Unauthorized',
- ], 401);
- }
-
+ $request = self::validateAuth($request);
$workflowInstance = WorkflowStub::load($workflowId);
$params = self::resolveNamedParameters(
$workflow,
$method->getName(),
- $request->except('workflow_id')
+ $request->except('workflowId')
);
$workflowInstance->{$method->getName()}(...$params);
-
return response()->json([
'message' => 'Signal sent',
]);
@@ -160,29 +152,20 @@ private static function resolveNamedParameters($class, $method, $payload)
return $params;
}
- private static function validateAuth(Request $request): bool
+ private static function validateAuth(Request $request): Request
{
- $config = config('workflows.webhook_auth', [
- 'method' => 'none',
- ]);
-
- if ($config['method'] === 'none') {
- return true;
- }
-
- if ($config['method'] === 'signature') {
- $secret = $config['signature']['secret'];
- $header = $config['signature']['header'];
- $expectedSignature = hash_hmac('sha256', $request->getContent(), $secret);
- return $request->header($header) === $expectedSignature;
- }
-
- if ($config['method'] === 'token') {
- $token = $config['token']['token'];
- $header = $config['token']['header'];
- return $request->header($header) === $token;
+ $authenticatorClass = match (config('workflows.webhook_auth.method', 'none')) {
+ 'none' => NullAuthenticator::class,
+ 'signature' => SignatureAuthenticator::class,
+ 'token' => TokenAuthenticator::class,
+ 'custom' => config('workflows.webhook_auth.custom.class'),
+ default => null,
+ };
+
+ if (! is_subclass_of($authenticatorClass, WebhookAuthenticator::class)) {
+ abort(401, 'Unauthorized');
}
- return false;
+ return (new $authenticatorClass())->validate($request);
}
}
diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php
index 54e7b09..9e61c5e 100644
--- a/src/WorkflowStub.php
+++ b/src/WorkflowStub.php
@@ -4,7 +4,6 @@
namespace Workflow;
-use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Traits\Macroable;
@@ -220,15 +219,11 @@ public function startAsChild(StoredWorkflow $parentWorkflow, int $index, $now, .
public function fail($exception): void
{
- try {
- $this->storedWorkflow->exceptions()
- ->create([
- 'class' => $this->storedWorkflow->class,
- 'exception' => Serializer::serialize($exception),
- ]);
- } catch (QueryException) {
- // already logged
- }
+ $this->storedWorkflow->exceptions()
+ ->create([
+ 'class' => $this->storedWorkflow->class,
+ 'exception' => Serializer::serialize($exception),
+ ]);
$this->storedWorkflow->status->transitionTo(WorkflowFailedStatus::class);
@@ -267,7 +262,7 @@ public function next($index, $now, $class, $result): void
'class' => $class,
'result' => Serializer::serialize($result),
]);
- } catch (QueryException) {
+ } catch (\Illuminate\Database\UniqueConstraintViolationException $exception) {
// already logged
}
diff --git a/src/config/workflows.php b/src/config/workflows.php
index 702c9fc..c01308a 100644
--- a/src/config/workflows.php
+++ b/src/config/workflows.php
@@ -37,18 +37,9 @@
'header' => env('WORKFLOW_WEBHOOKS_TOKEN_HEADER', 'Authorization'),
'token' => env('WORKFLOW_WEBHOOKS_TOKEN'),
],
- ],
-
- 'monitor' => env('WORKFLOW_MONITOR', false),
-
- 'monitor_url' => env('WORKFLOW_MONITOR_URL'),
-
- 'monitor_api_key' => env('WORKFLOW_MONITOR_API_KEY'),
- 'monitor_connection' => env('WORKFLOW_MONITOR_CONNECTION', config('queue.default')),
-
- 'monitor_queue' => env(
- 'WORKFLOW_MONITOR_QUEUE',
- config('queue.connections.' . config('queue.default') . '.queue', 'default')
- ),
+ 'custom' => [
+ 'class' => env('WORKFLOW_WEBHOOKS_CUSTOM_AUTH_CLASS', null),
+ ],
+ ],
];
diff --git a/testbench.yaml b/testbench.yaml
new file mode 100644
index 0000000..708a8fe
--- /dev/null
+++ b/testbench.yaml
@@ -0,0 +1,28 @@
+laravel: '@testbench'
+
+migrations:
+ - workbench/database/migrations
+
+seeders:
+ - Workbench\Database\Seeders\DatabaseSeeder
+
+workbench:
+ start: '/'
+ install: true
+ discovers:
+ web: true
+ api: false
+ commands: false
+ components: false
+ views: false
+ build:
+ - asset-publish
+ - create-sqlite-db
+ - db-wipe
+ - migrate-fresh
+ assets:
+ - laravel-assets
+ sync:
+ - from: storage
+ to: workbench/storage
+ reverse: true
diff --git a/tests/.env.feature b/tests/.env.feature
new file mode 100644
index 0000000..d47a269
--- /dev/null
+++ b/tests/.env.feature
@@ -0,0 +1,14 @@
+APP_KEY=base64:i3g6f+dV8FfsIkcxqd7gbiPn2oXk5r00sTmdD6V5utI=
+
+DB_CONNECTION=pgsql
+DB_DATABASE=laravel
+DB_HOST=db
+DB_PORT=5432
+DB_USERNAME=laravel
+DB_PASSWORD=laravel
+
+QUEUE_CONNECTION=redis
+
+REDIS_HOST=redis
+REDIS_PASSWORD=
+REDIS_PORT=6379
diff --git a/.env.example b/tests/.env.unit
similarity index 100%
rename from .env.example
rename to tests/.env.unit
diff --git a/tests/Feature/Base64WorkflowTest.php b/tests/Feature/Base64WorkflowTest.php
index b933a3f..d27ed59 100644
--- a/tests/Feature/Base64WorkflowTest.php
+++ b/tests/Feature/Base64WorkflowTest.php
@@ -17,7 +17,7 @@ final class Base64WorkflowTest extends TestCase
public function testBase64ToY(): void
{
config([
- 'serializer' => Base64::class,
+ 'workflows.serializer' => Base64::class,
]);
$workflow = WorkflowStub::make(TestExceptionWorkflow::class);
@@ -30,7 +30,7 @@ public function testBase64ToY(): void
$this->assertSame('workflow_activity_other', $workflow->output());
config([
- 'serializer' => Y::class,
+ 'workflows.serializer' => Y::class,
]);
if ($workflow->exceptions()->first()) {
@@ -44,7 +44,7 @@ public function testBase64ToY(): void
public function testYToBase64(): void
{
config([
- 'serializer' => Y::class,
+ 'workflows.serializer' => Y::class,
]);
$workflow = WorkflowStub::make(TestExceptionWorkflow::class);
@@ -57,7 +57,7 @@ public function testYToBase64(): void
$this->assertSame('workflow_activity_other', $workflow->output());
config([
- 'serializer' => Base64::class,
+ 'workflows.serializer' => Base64::class,
]);
if ($workflow->exceptions()->first()) {
diff --git a/tests/Feature/ExceptionWorkflowTest.php b/tests/Feature/ExceptionWorkflowTest.php
index 30e4f02..29362f0 100644
--- a/tests/Feature/ExceptionWorkflowTest.php
+++ b/tests/Feature/ExceptionWorkflowTest.php
@@ -4,8 +4,8 @@
namespace Tests\Feature;
-use Tests\Fixtures\NonRetryableTestExceptionWorkflow;
use Tests\Fixtures\TestExceptionWorkflow;
+use Tests\Fixtures\TestNonRetryableExceptionWorkflow;
use Tests\TestCase;
use Workflow\Serializers\Serializer;
use Workflow\States\WorkflowCompletedStatus;
@@ -34,7 +34,7 @@ public function testRetry(): void
public function testNonRetryableException(): void
{
- $workflow = WorkflowStub::make(NonRetryableTestExceptionWorkflow::class);
+ $workflow = WorkflowStub::make(TestNonRetryableExceptionWorkflow::class);
$workflow->start();
diff --git a/tests/Feature/WebhookWorkflowTest.php b/tests/Feature/WebhookWorkflowTest.php
index 77cf570..252613e 100644
--- a/tests/Feature/WebhookWorkflowTest.php
+++ b/tests/Feature/WebhookWorkflowTest.php
@@ -111,7 +111,7 @@ public function testSignatureAuth()
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
$response = $this->postJson('/webhooks/start/test-webhook-workflow', [], [
@@ -120,7 +120,7 @@ public function testSignatureAuth()
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
$response = $this->postJson('/webhooks/start/test-webhook-workflow', [], [
@@ -166,7 +166,7 @@ public function testTokenAuth()
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
$response = $this->postJson('/webhooks/start/test-webhook-workflow', [], [
@@ -175,7 +175,7 @@ public function testTokenAuth()
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
$response = $this->postJson('/webhooks/start/test-webhook-workflow', [], [
diff --git a/tests/Fixtures/StateMachine.php b/tests/Fixtures/StateMachine.php
index 6a42ea1..cd735a3 100644
--- a/tests/Fixtures/StateMachine.php
+++ b/tests/Fixtures/StateMachine.php
@@ -44,7 +44,7 @@ public function apply($action)
if (isset($this->transitions[$action]) && $this->transitions[$action]['from'] === $this->currentState) {
$this->currentState = $this->transitions[$action]['to'];
} else {
- throw new Exception('Transition not found,');
+ throw new Exception('Transition not found.');
}
}
}
diff --git a/tests/Fixtures/TestAuthenticator.php b/tests/Fixtures/TestAuthenticator.php
new file mode 100644
index 0000000..604fe5f
--- /dev/null
+++ b/tests/Fixtures/TestAuthenticator.php
@@ -0,0 +1,19 @@
+header('Authorization')) {
+ return $request;
+ }
+ abort(401, 'Unauthorized');
+ }
+}
diff --git a/tests/Fixtures/TestModelNotFoundWorkflow.php b/tests/Fixtures/TestModelNotFoundWorkflow.php
new file mode 100644
index 0000000..94d5d48
--- /dev/null
+++ b/tests/Fixtures/TestModelNotFoundWorkflow.php
@@ -0,0 +1,19 @@
+safeLoad();
+ if (getenv('GITHUB_ACTIONS') !== 'true') {
+ if (TestSuiteSubscriber::getCurrentSuite() === 'feature') {
+ Dotenv::createImmutable(__DIR__, '.env.feature')->safeLoad();
+ } elseif (TestSuiteSubscriber::getCurrentSuite() === 'unit') {
+ Dotenv::createImmutable(__DIR__, '.env.unit')->safeLoad();
+ }
+ }
for ($i = 0; $i < self::NUMBER_OF_WORKERS; $i++) {
- self::$workers[$i] = new Process(['php', 'artisan', 'queue:work']);
+ self::$workers[$i] = new Process(['php', __DIR__ . '/../vendor/bin/testbench', 'queue:work']);
self::$workers[$i]->start();
}
}
@@ -31,6 +37,19 @@ public static function tearDownAfterClass(): void
}
}
+ protected function setUp(): void
+ {
+ if (getenv('GITHUB_ACTIONS') !== 'true') {
+ if (TestSuiteSubscriber::getCurrentSuite() === 'feature') {
+ Dotenv::createImmutable(__DIR__, '.env.feature')->safeLoad();
+ } elseif (TestSuiteSubscriber::getCurrentSuite() === 'unit') {
+ Dotenv::createImmutable(__DIR__, '.env.unit')->safeLoad();
+ }
+ }
+
+ parent::setUp();
+ }
+
protected function defineDatabaseMigrations()
{
$this->artisan('migrate:fresh', [
diff --git a/tests/TestSuiteSubscriber.php b/tests/TestSuiteSubscriber.php
new file mode 100644
index 0000000..42fff0b
--- /dev/null
+++ b/tests/TestSuiteSubscriber.php
@@ -0,0 +1,28 @@
+testSuite()
+ ->name();
+
+ if (in_array($suiteName, ['unit', 'feature'], true)) {
+ self::$currentSuite = $suiteName;
+ }
+ }
+
+ public static function getCurrentSuite(): string
+ {
+ return self::$currentSuite;
+ }
+}
diff --git a/tests/Unit/ActivityTest.php b/tests/Unit/ActivityTest.php
index e0ffd3e..7e1b8ff 100644
--- a/tests/Unit/ActivityTest.php
+++ b/tests/Unit/ActivityTest.php
@@ -8,9 +8,11 @@
use Exception;
use Tests\Fixtures\TestExceptionActivity;
use Tests\Fixtures\TestInvalidActivity;
+use Tests\Fixtures\TestNonRetryableExceptionActivity;
use Tests\Fixtures\TestOtherActivity;
use Tests\Fixtures\TestWorkflow;
use Tests\TestCase;
+use Workflow\Exceptions\NonRetryableException;
use Workflow\Models\StoredWorkflow;
use Workflow\Serializers\Serializer;
use Workflow\States\WorkflowCreatedStatus;
@@ -64,6 +66,24 @@ public function testExceptionActivity(): void
$this->assertSame(WorkflowFailedStatus::class, $workflow->status());
}
+ public function testNonRetryableExceptionActivity(): void
+ {
+ $this->expectException(NonRetryableException::class);
+
+ $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $activity = new TestNonRetryableExceptionActivity(0, now()->toDateTimeString(), StoredWorkflow::findOrFail(
+ $workflow->id()
+ ));
+
+ $activity->handle();
+
+ $workflow->fresh();
+
+ $this->assertSame(1, $workflow->exceptions()->count());
+ $this->assertSame(0, $workflow->logs()->count());
+ $this->assertSame(WorkflowFailedStatus::class, $workflow->status());
+ }
+
public function testFailedActivity(): void
{
$workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
diff --git a/tests/Unit/Config/WorkflowsConfigTest.php b/tests/Unit/Config/WorkflowsConfigTest.php
new file mode 100644
index 0000000..28ad829
--- /dev/null
+++ b/tests/Unit/Config/WorkflowsConfigTest.php
@@ -0,0 +1,41 @@
+assertNotEmpty($config, 'The workflows config file is not loaded.');
+
+ $expectedConfig = [
+ 'workflows_folder' => 'Workflows',
+ 'base_model' => \Illuminate\Database\Eloquent\Model::class,
+ 'stored_workflow_model' => \Workflow\Models\StoredWorkflow::class,
+ 'stored_workflow_exception_model' => \Workflow\Models\StoredWorkflowException::class,
+ 'stored_workflow_log_model' => \Workflow\Models\StoredWorkflowLog::class,
+ 'stored_workflow_signal_model' => \Workflow\Models\StoredWorkflowSignal::class,
+ 'stored_workflow_timer_model' => \Workflow\Models\StoredWorkflowTimer::class,
+ 'workflow_relationships_table' => 'workflow_relationships',
+ 'serializer' => \Workflow\Serializers\Y::class,
+ 'prune_age' => '1 month',
+ 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'),
+ ];
+
+ foreach ($expectedConfig as $key => $expectedValue) {
+ $this->assertTrue(array_key_exists($key, $config), "The config key [workflows.{$key}] is missing.");
+
+ $this->assertEquals(
+ $expectedValue,
+ $config[$key],
+ "The config key [workflows.{$key}] does not match the expected value."
+ );
+ }
+ }
+}
diff --git a/tests/Unit/ExceptionTest.php b/tests/Unit/ExceptionTest.php
new file mode 100644
index 0000000..dac9eef
--- /dev/null
+++ b/tests/Unit/ExceptionTest.php
@@ -0,0 +1,46 @@
+toDateTimeString(), new StoredWorkflow(), new \Exception(
+ 'Test exception'
+ ));
+
+ $middleware = collect($exception->middleware())
+ ->map(static fn ($middleware) => is_object($middleware) ? get_class($middleware) : $middleware)
+ ->values();
+
+ $this->assertCount(1, $middleware);
+ $this->assertSame([WithoutOverlappingMiddleware::class], $middleware->all());
+ }
+
+ public function testExceptionWorkflowRunning(): void
+ {
+ $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowRunningStatus::$name,
+ ]);
+
+ $exception = new Exception(0, now()->toDateTimeString(), $storedWorkflow, new \Exception('Test exception'));
+ $exception->handle();
+
+ $this->assertSame(WorkflowRunningStatus::class, $workflow->status());
+ }
+}
diff --git a/tests/Unit/Exceptions/NonRetryableExceptionTest.php b/tests/Unit/Exceptions/NonRetryableExceptionTest.php
new file mode 100644
index 0000000..ab4ba0a
--- /dev/null
+++ b/tests/Unit/Exceptions/NonRetryableExceptionTest.php
@@ -0,0 +1,21 @@
+assertSame('test', $exception->getMessage());
+ $this->assertSame(1, $exception->getCode());
+ $this->assertInstanceOf(Exception::class, $exception->getPrevious());
+ }
+}
diff --git a/tests/Unit/Listeners/MonitorActivityCompletedTest.php b/tests/Unit/Listeners/MonitorActivityCompletedTest.php
deleted file mode 100644
index ee2a4b6..0000000
--- a/tests/Unit/Listeners/MonitorActivityCompletedTest.php
+++ /dev/null
@@ -1,55 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- $activityId = (string) Str::uuid();
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- "rest/v1/activities?id=eq.{$activityId}" => Http::response(),
- ]);
-
- $event = new ActivityCompleted(1, $activityId, 'output', 'time');
- $listener = new MonitorActivityCompleted();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) use ($activityId) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === "http://test/rest/v1/activities?id=eq.{$activityId}" &&
- $data->status === 'completed' &&
- $data->output === 'output' &&
- $data->updated_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Listeners/MonitorActivityFailedTest.php b/tests/Unit/Listeners/MonitorActivityFailedTest.php
deleted file mode 100644
index 78b1dd9..0000000
--- a/tests/Unit/Listeners/MonitorActivityFailedTest.php
+++ /dev/null
@@ -1,55 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- $activityId = (string) Str::uuid();
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- "rest/v1/activities?id=eq.{$activityId}" => Http::response(),
- ]);
-
- $event = new ActivityFailed(1, $activityId, 'output', 'time');
- $listener = new MonitorActivityFailed();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) use ($activityId) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === "http://test/rest/v1/activities?id=eq.{$activityId}" &&
- $data->status === 'failed' &&
- $data->output === 'output' &&
- $data->updated_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Listeners/MonitorActivityStartedTest.php b/tests/Unit/Listeners/MonitorActivityStartedTest.php
deleted file mode 100644
index f11213f..0000000
--- a/tests/Unit/Listeners/MonitorActivityStartedTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- $activityId = (string) Str::uuid();
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- 'rest/v1/activities' => Http::response(),
- ]);
-
- $event = new ActivityStarted(1, $activityId, 'class', 0, 'arguments', 'time');
- $listener = new MonitorActivityStarted();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) use ($activityId) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === 'http://test/rest/v1/activities' &&
- $data->user_id === 'user' &&
- $data->workflow_id === 1 &&
- $data->id === $activityId &&
- $data->index === 0 &&
- $data->class === 'class' &&
- $data->status === 'running' &&
- $data->arguments === 'arguments' &&
- $data->created_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Listeners/MonitorWorkflowCompletedTest.php b/tests/Unit/Listeners/MonitorWorkflowCompletedTest.php
deleted file mode 100644
index c17baee..0000000
--- a/tests/Unit/Listeners/MonitorWorkflowCompletedTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- 'rest/v1/workflows?user_id=eq.user&workflow_id=eq.1' => Http::response(),
- ]);
-
- $event = new WorkflowCompleted(1, 'output', 'time');
- $listener = new MonitorWorkflowCompleted();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === 'http://test/rest/v1/workflows?user_id=eq.user&workflow_id=eq.1' &&
- $data->status === 'completed' &&
- $data->output === 'output' &&
- $data->updated_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Listeners/MonitorWorkflowFailedTest.php b/tests/Unit/Listeners/MonitorWorkflowFailedTest.php
deleted file mode 100644
index ff07608..0000000
--- a/tests/Unit/Listeners/MonitorWorkflowFailedTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- 'rest/v1/workflows?user_id=eq.user&workflow_id=eq.1' => Http::response(),
- ]);
-
- $event = new WorkflowFailed(1, 'output', 'time');
- $listener = new MonitorWorkflowFailed();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === 'http://test/rest/v1/workflows?user_id=eq.user&workflow_id=eq.1' &&
- $data->status === 'failed' &&
- $data->output === 'output' &&
- $data->updated_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Listeners/MonitorWorkflowStartedTest.php b/tests/Unit/Listeners/MonitorWorkflowStartedTest.php
deleted file mode 100644
index f57f1e3..0000000
--- a/tests/Unit/Listeners/MonitorWorkflowStartedTest.php
+++ /dev/null
@@ -1,55 +0,0 @@
- 'http://test',
- ]);
- config([
- 'workflows.monitor_api_key' => 'key',
- ]);
-
- Http::fake([
- 'functions/v1/get-user' => Http::response([
- 'user' => 'user',
- 'public' => 'public',
- 'token' => 'token',
- ]),
- 'rest/v1/workflows' => Http::response(),
- ]);
-
- $event = new WorkflowStarted(1, 'class', 'arguments', 'time');
- $listener = new MonitorWorkflowStarted();
- $listener->handle($event);
-
- Http::assertSent(static function (Request $request) {
- return $request->hasHeader('Authorization', 'Bearer key') &&
- $request->url() === 'http://test/functions/v1/get-user';
- });
-
- Http::assertSent(static function (Request $request) {
- $data = json_decode($request->body());
- return $request->hasHeader('apiKey', 'public') &&
- $request->hasHeader('Authorization', 'Bearer token') &&
- $request->url() === 'http://test/rest/v1/workflows' &&
- $data->user_id === 'user' &&
- $data->workflow_id === 1 &&
- $data->class === 'class' &&
- $data->status === 'running' &&
- $data->arguments === 'arguments' &&
- $data->created_at === 'time';
- });
- }
-}
diff --git a/tests/Unit/Middleware/ActivityMiddlewareTest.php b/tests/Unit/Middleware/ActivityMiddlewareTest.php
index 9c387a4..b9e94d2 100644
--- a/tests/Unit/Middleware/ActivityMiddlewareTest.php
+++ b/tests/Unit/Middleware/ActivityMiddlewareTest.php
@@ -5,10 +5,12 @@
namespace Tests\Unit\Middleware;
use Exception;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Mockery\MockInterface;
use Tests\Fixtures\TestActivity;
+use Tests\Fixtures\TestModelNotFoundWorkflow;
use Tests\Fixtures\TestWorkflow;
use Tests\TestCase;
use Workflow\Events\ActivityCompleted;
@@ -17,6 +19,7 @@
use Workflow\Middleware\ActivityMiddleware;
use Workflow\Models\StoredWorkflow;
use Workflow\States\WorkflowCompletedStatus;
+use Workflow\States\WorkflowFailedStatus;
use Workflow\States\WorkflowRunningStatus;
use Workflow\States\WorkflowWaitingStatus;
use Workflow\WorkflowStub;
@@ -151,4 +154,76 @@ public function testException(): void
Event::assertDispatched(ActivityFailed::class);
Queue::assertPushed(TestWorkflow::class, 1);
}
+
+ public function testModelNotFoundException(): void
+ {
+ Event::fake();
+ Queue::fake();
+
+ $workflow = WorkflowStub::make(TestWorkflow::class);
+ $workflow->start();
+
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->update([
+ 'status' => WorkflowWaitingStatus::class,
+ ]);
+
+ $activity = $this->mock(TestActivity::class);
+ $activity->index = 0;
+ $activity->now = now()
+ ->toDateTimeString();
+ $activity->storedWorkflow = $storedWorkflow;
+
+ $middleware = new ActivityMiddleware();
+
+ try {
+ $middleware->handle($activity, static function ($job) {
+ throw new ModelNotFoundException('test');
+ });
+ } catch (Exception $exception) {
+ $this->assertSame('test', $exception->getMessage());
+ }
+
+ Event::assertDispatched(ActivityStarted::class);
+ Event::assertDispatched(ActivityFailed::class);
+ Queue::assertPushed(TestWorkflow::class, 1);
+
+ $this->assertSame(WorkflowWaitingStatus::class, $workflow->status());
+ }
+
+ public function testModelNotFoundExceptionInNextMethod(): void
+ {
+ Event::fake();
+ Queue::fake();
+
+ $deletedWorkflow = StoredWorkflow::create([
+ 'class' => TestWorkflow::class,
+ ]);
+
+ $workflow = WorkflowStub::make(TestModelNotFoundWorkflow::class);
+ $workflow->start($deletedWorkflow);
+
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->update([
+ 'status' => WorkflowWaitingStatus::class,
+ ]);
+
+ $deletedWorkflow->delete();
+
+ $activity = $this->mock(TestActivity::class);
+ $activity->index = 0;
+ $activity->now = now()
+ ->toDateTimeString();
+ $activity->storedWorkflow = $storedWorkflow;
+
+ $middleware = new ActivityMiddleware();
+
+ $middleware->handle($activity, static function ($job) {
+ return true;
+ });
+
+ $this->assertSame(WorkflowFailedStatus::class, $workflow->status());
+
+ Queue::assertPushed(TestWorkflow::class, 0);
+ }
}
diff --git a/tests/Unit/Middleware/WithoutOverlappingMiddlewareTest.php b/tests/Unit/Middleware/WithoutOverlappingMiddlewareTest.php
index 10b1754..7fe6571 100644
--- a/tests/Unit/Middleware/WithoutOverlappingMiddlewareTest.php
+++ b/tests/Unit/Middleware/WithoutOverlappingMiddlewareTest.php
@@ -4,6 +4,8 @@
namespace Tests\Unit\Middleware;
+use Illuminate\Contracts\Cache\Lock;
+use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
use Mockery\MockInterface;
use Tests\Fixtures\TestActivity;
@@ -15,6 +17,10 @@ final class WithoutOverlappingMiddlewareTest extends TestCase
{
public function testMiddleware(): void
{
+ $this->app->make('cache')
+ ->store()
+ ->clear();
+
$middleware = new WithoutOverlappingMiddleware(1, WithoutOverlappingMiddleware::WORKFLOW);
$this->assertSame($middleware->getLockKey(), 'laravel-workflow-overlap:1');
$this->assertSame($middleware->getWorkflowSemaphoreKey(), 'laravel-workflow-overlap:1:workflow');
@@ -28,6 +34,10 @@ public function testMiddleware(): void
public function testAllowsOnlyOneWorkflowInstance(): void
{
+ $this->app->make('cache')
+ ->store()
+ ->clear();
+
$workflow1 = $this->mock(TestWorkflow::class);
$middleware1 = new WithoutOverlappingMiddleware(1, WithoutOverlappingMiddleware::WORKFLOW);
@@ -65,6 +75,10 @@ public function testAllowsOnlyOneWorkflowInstance(): void
public function testAllowsMultipleActivityInstances(): void
{
+ $this->app->make('cache')
+ ->store()
+ ->clear();
+
$activity1 = $this->mock(TestActivity::class);
$middleware1 = new WithoutOverlappingMiddleware(1, WithoutOverlappingMiddleware::ACTIVITY);
@@ -99,4 +113,60 @@ public function testAllowsMultipleActivityInstances(): void
$this->assertNull(Cache::get($middleware1->getWorkflowSemaphoreKey()));
$this->assertSame(0, count(Cache::get($middleware1->getActivitySemaphoreKey())));
}
+
+ public function testUnknownTypeDoesNotCallNext(): void
+ {
+ $this->app->make('cache')
+ ->store()
+ ->clear();
+
+ $job = $this->mock(TestActivity::class, static function (MockInterface $mock) {
+ $mock->shouldReceive('release')
+ ->once();
+ });
+
+ $middleware = new WithoutOverlappingMiddleware(1, 999);
+
+ $middleware->handle($job, function ($job) {
+ $this->fail('Should not call next when type is unknown');
+ });
+
+ $this->assertNull(Cache::get($middleware->getWorkflowSemaphoreKey()));
+ $this->assertNull(Cache::get($middleware->getActivitySemaphoreKey()));
+ }
+
+ public function testReleaseWhenCompareAndSetFails(): void
+ {
+ $this->app->make('cache')
+ ->store()
+ ->clear();
+
+ $job = $this->mock(TestWorkflow::class, static function (MockInterface $mock) {
+ $mock->shouldReceive('release')
+ ->once();
+ });
+
+ $lock = $this->mock(Lock::class, static function (MockInterface $mock) {
+ $mock->shouldReceive('get')
+ ->once()
+ ->andReturn(false);
+ });
+
+ $cache = $this->mock(Repository::class, static function (MockInterface $mock) use ($lock) {
+ $mock->shouldReceive('lock')
+ ->once()
+ ->andReturn($lock);
+ $mock->shouldReceive('get')
+ ->andReturn([]);
+ });
+
+ $middleware = new WithoutOverlappingMiddleware(1, WithoutOverlappingMiddleware::WORKFLOW);
+
+ $middleware->handle($job, function ($job) {
+ $this->fail('Should not call next when lock is not acquired');
+ });
+
+ $this->assertNull(Cache::get($middleware->getWorkflowSemaphoreKey()));
+ $this->assertNull(Cache::get($middleware->getActivitySemaphoreKey()));
+ }
}
diff --git a/tests/Unit/Providers/WorkflowServiceProviderTest.php b/tests/Unit/Providers/WorkflowServiceProviderTest.php
new file mode 100644
index 0000000..7148c48
--- /dev/null
+++ b/tests/Unit/Providers/WorkflowServiceProviderTest.php
@@ -0,0 +1,59 @@
+app->register(WorkflowServiceProvider::class);
+ }
+
+ public function testProviderLoads(): void
+ {
+ $this->assertTrue(
+ $this->app->getProvider(WorkflowServiceProvider::class) instanceof WorkflowServiceProvider
+ );
+ }
+
+ public function testConfigIsPublished(): void
+ {
+ Artisan::call('vendor:publish', [
+ '--tag' => 'config',
+ ]);
+
+ $this->assertFileExists(config_path('workflows.php'));
+ }
+
+ public function testMigrationsArePublished(): void
+ {
+ Artisan::call('vendor:publish', [
+ '--tag' => 'migrations',
+ ]);
+
+ $migrationFiles = glob(database_path('migrations/*.php'));
+ $this->assertNotEmpty($migrationFiles, 'Migrations should be published');
+ }
+
+ public function testCommandsAreRegistered(): void
+ {
+ $registeredCommands = array_keys(Artisan::all());
+
+ $expectedCommands = ['make:activity', 'make:workflow'];
+
+ foreach ($expectedCommands as $command) {
+ $this->assertContains(
+ $command,
+ $registeredCommands,
+ "Command [{$command}] is not registered in Artisan."
+ );
+ }
+ }
+}
diff --git a/tests/Unit/Serializers/EncodeTest.php b/tests/Unit/Serializers/EncodeTest.php
index ab37d57..ae96098 100644
--- a/tests/Unit/Serializers/EncodeTest.php
+++ b/tests/Unit/Serializers/EncodeTest.php
@@ -17,7 +17,7 @@ final class EncodeTest extends TestCase
public function testYEncode(string $bytes): void
{
config([
- 'serializer' => Y::class,
+ 'workflows.serializer' => Y::class,
]);
$decoded = Serializer::decode(Serializer::encode($bytes));
$this->assertSame($bytes, $decoded);
@@ -29,13 +29,13 @@ public function testYEncode(string $bytes): void
public function testBase64Encode(string $bytes): void
{
config([
- 'serializer' => Base64::class,
+ 'workflows.serializer' => Base64::class,
]);
$decoded = Serializer::decode(Serializer::encode($bytes));
$this->assertSame($bytes, $decoded);
}
- public function dataProvider(): array
+ public static function dataProvider(): array
{
return [
'empty' => [''],
diff --git a/tests/Unit/Serializers/SerializeTest.php b/tests/Unit/Serializers/SerializeTest.php
index df63ed2..399e038 100644
--- a/tests/Unit/Serializers/SerializeTest.php
+++ b/tests/Unit/Serializers/SerializeTest.php
@@ -24,13 +24,15 @@ public function testSerialize($data): void
$this->testSerializeUnserialize($data, Base64::class, Y::class);
}
- public function dataProvider(): array
+ public static function dataProvider(): array
{
return [
'array []' => [[]],
'array [[]]' => [[[]]],
'array assoc' => [
- 'key' => 'value',
+ [
+ 'key' => 'value',
+ ],
],
'bool true' => [true],
'bool false' => [false],
@@ -56,14 +58,21 @@ public function dataProvider(): array
];
}
+ public function testSerializableReturnsFalseForClosure(): void
+ {
+ $this->assertFalse(Serializer::serializable(static function () {
+ return 'test';
+ }));
+ }
+
private function testSerializeUnserialize($data, $serializer, $unserializer): void
{
config([
- 'serializer' => $serializer,
+ 'workflows.serializer' => $serializer,
]);
$serialized = Serializer::serialize($data);
config([
- 'serializer' => $unserializer,
+ 'workflows.serializer' => $unserializer,
]);
$unserialized = Serializer::unserialize($serialized);
if (is_object($data)) {
diff --git a/tests/Unit/SignalTest.php b/tests/Unit/SignalTest.php
new file mode 100644
index 0000000..6470acc
--- /dev/null
+++ b/tests/Unit/SignalTest.php
@@ -0,0 +1,44 @@
+middleware())
+ ->map(static fn ($middleware) => is_object($middleware) ? get_class($middleware) : $middleware)
+ ->values();
+
+ $this->assertCount(1, $middleware);
+ $this->assertSame([WithoutOverlappingMiddleware::class], $middleware->all());
+ }
+
+ public function testSignalWorkflowRunning(): void
+ {
+ $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowRunningStatus::$name,
+ ]);
+
+ $signal = new Signal($storedWorkflow);
+ $signal->handle();
+
+ $this->assertSame(WorkflowRunningStatus::class, $workflow->status());
+ }
+}
diff --git a/tests/Unit/Traits/TimersTest.php b/tests/Unit/Traits/TimersTest.php
index cc03893..3b0363a 100644
--- a/tests/Unit/Traits/TimersTest.php
+++ b/tests/Unit/Traits/TimersTest.php
@@ -4,6 +4,10 @@
namespace Tests\Unit\Traits;
+use Exception;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\UniqueConstraintViolationException;
+use Mockery;
use Tests\Fixtures\TestWorkflow;
use Tests\TestCase;
use Workflow\Models\StoredWorkflow;
@@ -73,7 +77,7 @@ public function testDefersIfNotElapsed(): void
$this->assertNull($result);
$this->assertSame(0, $workflow->logs()->count());
- WorkflowStub::timer('1 minute', static fn () => false)
+ WorkflowStub::awaitWithTimeout('1 minute', static fn () => false)
->then(static function ($value) use (&$result) {
$result = $value;
});
@@ -124,7 +128,7 @@ public function testLoadsStoredResult(): void
'result' => Serializer::serialize(true),
]);
- WorkflowStub::timer('1 minute', static fn () => true)
+ WorkflowStub::awaitWithTimeout('1 minute', static fn () => true)
->then(static function ($value) use (&$result) {
$result = $value;
});
@@ -138,4 +142,56 @@ public function testLoadsStoredResult(): void
]);
$this->assertSame(true, Serializer::unserialize($workflow->logs()->firstWhere('index', 0)->result));
}
+
+ public function testHandlesDuplicateLogInsertionProperly(): void
+ {
+ $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->timers()
+ ->create([
+ 'index' => 0,
+ 'stop_at' => now(),
+ ]);
+ $storedWorkflow->logs()
+ ->create([
+ 'index' => 0,
+ 'now' => now(),
+ 'class' => Signal::class,
+ 'result' => Serializer::serialize(true),
+ ]);
+
+ $mockLogs = Mockery::mock(HasMany::class)
+ ->shouldReceive('whereIndex')
+ ->once()
+ ->andReturnSelf()
+ ->shouldReceive('first')
+ ->once()
+ ->andReturn(null)
+ ->shouldReceive('create')
+ ->andThrow(new UniqueConstraintViolationException('', '', [], new Exception()))
+ ->getMock();
+
+ $mockStoredWorkflow = Mockery::spy($storedWorkflow);
+
+ $mockStoredWorkflow->shouldReceive('logs')
+ ->andReturnUsing(static function () use ($mockLogs) {
+ return $mockLogs;
+ });
+
+ WorkflowStub::setContext([
+ 'storedWorkflow' => $mockStoredWorkflow,
+ 'index' => 0,
+ 'now' => now(),
+ 'replaying' => false,
+ ]);
+
+ WorkflowStub::timer('1 minute')
+ ->then(static function ($value) use (&$result) {
+ $result = $value;
+ });
+
+ Mockery::close();
+
+ $this->assertSame(true, $result);
+ }
}
diff --git a/tests/Unit/WebhooksTest.php b/tests/Unit/WebhooksTest.php
index 1e762a1..c811e6e 100644
--- a/tests/Unit/WebhooksTest.php
+++ b/tests/Unit/WebhooksTest.php
@@ -9,6 +9,8 @@
use Mockery;
use ReflectionClass;
use ReflectionMethod;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Tests\Fixtures\TestAuthenticator;
use Tests\TestCase;
use Workflow\Models\StoredWorkflow;
use Workflow\States\WorkflowPendingStatus;
@@ -126,7 +128,7 @@ public function testValidatesAuthForNone()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertTrue($method->invoke(null, $request));
+ $this->assertInstanceOf(Request::class, $method->invoke(null, $request));
}
public function testValidatesAuthForTokenSuccess()
@@ -145,11 +147,13 @@ public function testValidatesAuthForTokenSuccess()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertTrue($method->invoke(null, $request));
+ $this->assertInstanceOf(Request::class, $method->invoke(null, $request));
}
public function testValidatesAuthForTokenFailure()
{
+ $this->expectException(HttpException::class);
+
config([
'workflows.webhook_auth.method' => 'token',
'workflows.webhook_auth.token.token' => 'valid-token',
@@ -164,7 +168,7 @@ public function testValidatesAuthForTokenFailure()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertFalse($method->invoke(null, $request));
+ $method->invoke(null, $request);
}
public function testValidatesAuthForSignatureSuccess()
@@ -188,11 +192,13 @@ public function testValidatesAuthForSignatureSuccess()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertTrue($method->invoke(null, $request));
+ $this->assertInstanceOf(Request::class, $method->invoke(null, $request));
}
public function testValidatesAuthForSignatureFailure()
{
+ $this->expectException(HttpException::class);
+
config([
'workflows.webhook_auth.method' => 'signature',
'workflows.webhook_auth.signature.secret' => 'test-secret',
@@ -212,7 +218,43 @@ public function testValidatesAuthForSignatureFailure()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertFalse($method->invoke(null, $request));
+ $method->invoke(null, $request);
+ }
+
+ public function testValidatesAuthForCustomSuccess()
+ {
+ config([
+ 'workflows.webhook_auth.method' => 'custom',
+ 'workflows.webhook_auth.custom.class' => TestAuthenticator::class,
+ ]);
+
+ $request = Request::create('/webhooks/start/test-webhook-workflow', 'POST', [], [], [], [
+ 'HTTP_AUTHORIZATION' => 'valid-token',
+ ]);
+
+ $webhooksReflection = new ReflectionClass(Webhooks::class);
+ $method = $webhooksReflection->getMethod('validateAuth');
+ $method->setAccessible(true);
+
+ $this->assertInstanceOf(Request::class, $method->invoke(null, $request));
+ }
+
+ public function testValidatesAuthForCustomFailure()
+ {
+ $this->expectException(HttpException::class);
+
+ config([
+ 'workflows.webhook_auth.method' => 'custom',
+ 'workflows.webhook_auth.custom.class' => TestAuthenticator::class,
+ ]);
+
+ $request = Request::create('/webhooks/start/test-webhook-workflow', 'POST');
+
+ $webhooksReflection = new ReflectionClass(Webhooks::class);
+ $method = $webhooksReflection->getMethod('validateAuth');
+ $method->setAccessible(true);
+
+ $method->invoke(null, $request);
}
public function testResolveNamedParameters()
@@ -236,6 +278,8 @@ public function testResolveNamedParameters()
public function testUnauthorizedRequestFails()
{
+ $this->expectException(HttpException::class);
+
config([
'workflows.webhook_auth.method' => 'unsupported',
]);
@@ -246,7 +290,7 @@ public function testUnauthorizedRequestFails()
$method = $webhooksReflection->getMethod('validateAuth');
$method->setAccessible(true);
- $this->assertFalse($method->invoke(null, $request));
+ $method->invoke(null, $request);
}
public function testResolveNamedParametersUsesDefaults()
@@ -323,7 +367,7 @@ public function testStartUnauthorized(): void
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
}
@@ -350,7 +394,7 @@ public function testSignalUnauthorized(): void
$response->assertStatus(401);
$response->assertJson([
- 'error' => 'Unauthorized',
+ 'message' => 'Unauthorized',
]);
}
}
diff --git a/tests/Unit/WorkflowStubTest.php b/tests/Unit/WorkflowStubTest.php
index 0d03098..20bd90a 100644
--- a/tests/Unit/WorkflowStubTest.php
+++ b/tests/Unit/WorkflowStubTest.php
@@ -6,6 +6,7 @@
use Exception;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Queue;
use Tests\Fixtures\TestAwaitWorkflow;
use Tests\Fixtures\TestBadConnectionWorkflow;
use Tests\Fixtures\TestWorkflow;
@@ -14,6 +15,7 @@
use Workflow\Serializers\Serializer;
use Workflow\Signal;
use Workflow\States\WorkflowCompletedStatus;
+use Workflow\States\WorkflowCreatedStatus;
use Workflow\States\WorkflowPendingStatus;
use Workflow\WorkflowStub;
@@ -207,4 +209,38 @@ public function testConnection(): void
$this->assertSame('redis', WorkflowStub::connection());
$this->assertSame('default', WorkflowStub::queue());
}
+
+ public function testHandlesDuplicateLogInsertionProperly(): void
+ {
+ Queue::fake();
+
+ $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($workflow->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowCreatedStatus::$name,
+ ]);
+
+ $storedWorkflow->timers()
+ ->create([
+ 'index' => 0,
+ 'stop_at' => now(),
+ ]);
+ $storedWorkflow->logs()
+ ->create([
+ 'index' => 0,
+ 'now' => now(),
+ 'class' => Signal::class,
+ 'result' => Serializer::serialize(true),
+ ]);
+
+ $workflow = $storedWorkflow->toWorkflow();
+
+ $workflow->next(0, now(), Signal::class, true);
+
+ $this->assertSame(WorkflowPendingStatus::class, $workflow->status());
+ $this->assertSame(1, $workflow->logs()->count());
+
+ Queue::assertPushed(TestWorkflow::class, 1);
+ }
}
diff --git a/tests/Unit/WorkflowTest.php b/tests/Unit/WorkflowTest.php
index 08eeace..3f95402 100644
--- a/tests/Unit/WorkflowTest.php
+++ b/tests/Unit/WorkflowTest.php
@@ -4,22 +4,133 @@
namespace Tests\Unit;
+use BadMethodCallException;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Event;
use Tests\Fixtures\TestActivity;
use Tests\Fixtures\TestChildWorkflow;
use Tests\Fixtures\TestOtherActivity;
use Tests\Fixtures\TestParentWorkflow;
+use Tests\Fixtures\TestThrowOnReturnWorkflow;
use Tests\Fixtures\TestWorkflow;
+use Tests\Fixtures\TestYieldNonPromiseWorkflow;
use Tests\TestCase;
+use Workflow\Events\WorkflowFailed;
use Workflow\Exception;
use Workflow\Models\StoredWorkflow;
use Workflow\Serializers\Serializer;
use Workflow\States\WorkflowCompletedStatus;
+use Workflow\States\WorkflowFailedStatus;
use Workflow\States\WorkflowPendingStatus;
+use Workflow\Workflow;
use Workflow\WorkflowStub;
final class WorkflowTest extends TestCase
{
+ public function testFailed(): void
+ {
+ Event::fake();
+
+ $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id());
+ $storedParentWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::$name,
+ ]);
+
+ $stub = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::class,
+ ]);
+
+ $storedWorkflow->parents()
+ ->attach($storedParentWorkflow, [
+ 'parent_index' => 0,
+ 'parent_now' => now(),
+ ]);
+
+ $workflow = new Workflow($storedWorkflow);
+
+ $workflow->failed(new \Exception('Test exception'));
+
+ $this->assertSame(WorkflowFailedStatus::class, $stub->status());
+ $this->assertSame(
+ 'Test exception',
+ Serializer::unserialize($stub->exceptions()->first()->exception)['message']
+ );
+
+ Event::assertDispatched(WorkflowFailed::class, static function ($event) use ($stub) {
+ return $event->workflowId === $stub->id();
+ });
+ }
+
+ public function testFailedTwice(): void
+ {
+ Event::fake();
+
+ $stub = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowFailedStatus::class,
+ ]);
+
+ $workflow = new Workflow($storedWorkflow);
+
+ $workflow->failed(new \Exception('Test exception'));
+
+ $this->assertSame(WorkflowFailedStatus::class, $stub->status());
+ $this->assertSame(
+ 'Test exception',
+ Serializer::unserialize($stub->exceptions()->first()->exception)['message']
+ );
+
+ Event::assertNotDispatched(WorkflowFailed::class, static function ($event) use ($stub) {
+ return $event->workflowId === $stub->id();
+ });
+ }
+
+ public function testFailedWithParentFailed(): void
+ {
+ Event::fake();
+
+ $parentWorkflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedParentWorkflow = StoredWorkflow::findOrFail($parentWorkflow->id());
+ $storedParentWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowFailedStatus::$name,
+ ]);
+
+ $stub = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::class,
+ ]);
+
+ $storedWorkflow->parents()
+ ->attach($storedParentWorkflow, [
+ 'parent_index' => 0,
+ 'parent_now' => now(),
+ ]);
+
+ $workflow = new Workflow($storedWorkflow);
+
+ $workflow->failed(new \Exception('Test exception'));
+
+ $this->assertSame(WorkflowFailedStatus::class, $stub->status());
+ $this->assertSame(
+ 'Test exception',
+ Serializer::unserialize($stub->exceptions()->first()->exception)['message']
+ );
+
+ Event::assertDispatched(WorkflowFailed::class, static function ($event) use ($stub) {
+ return $event->workflowId === $stub->id();
+ });
+ }
+
public function testException(): void
{
$exception = new \Exception('test');
@@ -168,4 +279,52 @@ public function testParentPending(): void
$this->assertSame(WorkflowCompletedStatus::class, $childWorkflow->status());
$this->assertSame('other', $childWorkflow->output());
}
+
+ public function testThrowsWhenExecuteMethodIsMissing(): void
+ {
+ $stub = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::class,
+ ]);
+
+ $this->expectException(BadMethodCallException::class);
+ $this->expectExceptionMessage('Execute method not implemented.');
+
+ $workflow = new Workflow($storedWorkflow);
+ $workflow->handle();
+ }
+
+ public function testThrowsWhenYieldNonPromise(): void
+ {
+ $stub = WorkflowStub::load(WorkflowStub::make(TestYieldNonPromiseWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::class,
+ ]);
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('something went wrong');
+
+ $workflow = new TestYieldNonPromiseWorkflow($storedWorkflow);
+ $workflow->handle();
+ }
+
+ public function testThrowsWrappedException(): void
+ {
+ $stub = WorkflowStub::load(WorkflowStub::make(TestThrowOnReturnWorkflow::class)->id());
+ $storedWorkflow = StoredWorkflow::findOrFail($stub->id());
+ $storedWorkflow->update([
+ 'arguments' => Serializer::serialize([]),
+ 'status' => WorkflowPendingStatus::class,
+ ]);
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Workflow failed.');
+
+ $workflow = new TestThrowOnReturnWorkflow($storedWorkflow);
+ $workflow->handle();
+ }
}
diff --git a/tests/Unit/migrations/MigrationsTest.php b/tests/Unit/migrations/MigrationsTest.php
new file mode 100644
index 0000000..ae24cc0
--- /dev/null
+++ b/tests/Unit/migrations/MigrationsTest.php
@@ -0,0 +1,33 @@
+assertTrue(Schema::hasTable('workflows'));
+ $this->assertTrue(Schema::hasTable('workflow_logs'));
+ $this->assertTrue(Schema::hasTable('workflow_signals'));
+ $this->assertTrue(Schema::hasTable('workflow_timers'));
+ $this->assertTrue(Schema::hasTable('workflow_exceptions'));
+ $this->assertTrue(Schema::hasTable('workflow_relationships'));
+
+ $this->artisan('migrate:reset', [
+ '--path' => dirname(__DIR__, 3) . '/src/migrations',
+ '--realpath' => true,
+ ])->run();
+
+ $this->assertFalse(Schema::hasTable('workflows'));
+ $this->assertFalse(Schema::hasTable('workflow_logs'));
+ $this->assertFalse(Schema::hasTable('workflow_signals'));
+ $this->assertFalse(Schema::hasTable('workflow_timers'));
+ $this->assertFalse(Schema::hasTable('workflow_exceptions'));
+ $this->assertFalse(Schema::hasTable('workflow_relationships'));
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..25b78bc
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,11 @@
+registerSubscribers($subscriber);
diff --git a/classAliases.php b/tests/classAliases.php
similarity index 84%
rename from classAliases.php
rename to tests/classAliases.php
index 3dde4cf..aa4ea67 100644
--- a/classAliases.php
+++ b/tests/classAliases.php
@@ -1,5 +1,7 @@
+ */
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var array
+ */
+ protected $hidden = [
+ 'password',
+ 'remember_token',
+ ];
+
+ /**
+ * The attributes that should be cast.
+ *
+ * @var array
+ */
+ protected $casts = [
+ 'email_verified_at' => 'datetime',
+ 'password' => 'hashed',
+ ];
+}
diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php
new file mode 100644
index 0000000..e8cec9c
--- /dev/null
+++ b/workbench/app/Providers/WorkbenchServiceProvider.php
@@ -0,0 +1,24 @@
+
+ */
+class UserFactory extends Factory
+{
+ /**
+ * The current password being used by the factory.
+ */
+ protected static ?string $password;
+
+ /**
+ * The name of the factory's corresponding model.
+ *
+ * @var string
+ */
+ protected $model = User::class;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->name(),
+ 'email' => fake()->unique()->safeEmail(),
+ 'email_verified_at' => now(),
+ 'password' => static::$password ??= Hash::make('password'),
+ 'remember_token' => Str::random(10),
+ ];
+ }
+
+ /**
+ * Indicate that the model's email address should be unverified.
+ */
+ public function unverified(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'email_verified_at' => null,
+ ]);
+ }
+}
diff --git a/workbench/database/migrations/.gitkeep b/workbench/database/migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php
new file mode 100644
index 0000000..1252f3c
--- /dev/null
+++ b/workbench/database/seeders/DatabaseSeeder.php
@@ -0,0 +1,23 @@
+times(10)->create();
+
+ // UserFactory::new()->create([
+ // 'name' => 'Test User',
+ // 'email' => 'test@example.com',
+ // ]);
+ }
+}
diff --git a/workbench/resources/views/.gitkeep b/workbench/resources/views/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/workbench/routes/api.php b/workbench/routes/api.php
new file mode 100644
index 0000000..b95130d
--- /dev/null
+++ b/workbench/routes/api.php
@@ -0,0 +1,19 @@
+get('/user', function (Request $request) {
+// return $request->user();
+// });
diff --git a/workbench/routes/console.php b/workbench/routes/console.php
new file mode 100644
index 0000000..3c0324c
--- /dev/null
+++ b/workbench/routes/console.php
@@ -0,0 +1,19 @@
+comment(Inspiring::quote());
+// })->purpose('Display an inspiring quote');
diff --git a/workbench/routes/web.php b/workbench/routes/web.php
new file mode 100644
index 0000000..d259f33
--- /dev/null
+++ b/workbench/routes/web.php
@@ -0,0 +1,18 @@
+
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy