diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc97bd..3227ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ # Changelog All notable changes to `workflow-engine-laravel` will be documented in this file. + +## v0.0.1-alpha - 2025-05-29 + +### What's Changed + +* Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/solutionforest/workflow-engine-laravel/pull/1 +* Feature/core by @lam0819 in https://github.com/solutionforest/workflow-engine-laravel/pull/3 + +### New Contributors + +* @dependabot made their first contribution in https://github.com/solutionforest/workflow-engine-laravel/pull/1 +* @lam0819 made their first contribution in https://github.com/solutionforest/workflow-engine-laravel/pull/3 + +**Full Changelog**: https://github.com/solutionforest/workflow-engine-laravel/commits/v0.0.1-alpha diff --git a/README.md b/README.md index 6b84ca9..7c77120 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ Create powerful business workflows with simple, maintainable code. +> ⚠️ **WARNING: DEVELOPMENT STATUS**⚠️ +> +> This package is currently under active development and is **NOT READY FOR PRODUCTION USE**. +> +> Features may be incomplete, APIs might change, and there could be breaking changes. Use at your own risk in development environments only. + + ## ✨ Why Choose This Workflow Engine? - 🎨 **Simple & Intuitive** - Array-based workflow definitions and fluent WorkflowBuilder API diff --git a/composer.json b/composer.json index b459fd2..03cf64a 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "illuminate/database": "^10.0||^11.0||^12.0", "illuminate/events": "^10.0||^11.0||^12.0", "illuminate/support": "^10.0||^11.0||^12.0", - "solution-forest/workflow-engine-core": "^0.0.1-alpha" + "solution-forest/workflow-engine-core": "^0.0.2-alpha" }, "conflict": { "laravel/framework": "<11.0.0" diff --git a/docs/advanced-features.md b/docs/advanced-features.md index f5f50cb..251928a 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -131,10 +131,8 @@ Configure automatic retries for unreliable operations: ```php $workflow = WorkflowBuilder::create('robust-workflow') - ->step('api-call', ApiCallAction::class) - ->retry(attempts: 3, backoff: 'exponential') - ->step('database-operation', DatabaseAction::class) - ->retry(attempts: 5, backoff: 'linear') + ->addStep('api-call', ApiCallAction::class, [], null, 3) // 3 retry attempts + ->addStep('database-operation', DatabaseAction::class, [], null, 5) // 5 retry attempts ->build(); ``` @@ -147,7 +145,7 @@ $workflow = WorkflowBuilder::create('robust-workflow') ```php // Custom backoff function $workflow = WorkflowBuilder::create('custom-retry') - ->step('operation', MyAction::class) + ->addStep('operation', MyAction::class, [], null, 3) // Basic retry with 3 attempts ->retry(attempts: 3, backoff: function($attempt) { return $attempt * 1000; // 1s, 2s, 3s }) diff --git a/docs/api-reference.md b/docs/api-reference.md index f9bbb31..ccd7a0f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -14,32 +14,29 @@ Creates a new workflow builder instance. $builder = WorkflowBuilder::create('my-workflow'); ``` -#### `step(string $id, string $actionClass): self` +#### `addStep(string $id, string $actionClass, array $config = [], ?int $timeout = null, int $retryAttempts = 0): self` Adds a custom action step to the workflow. ```php -$builder->step('process-payment', ProcessPaymentAction::class); +$builder->addStep('process-payment', ProcessPaymentAction::class); +$builder->addStep('process-payment', ProcessPaymentAction::class, ['currency' => 'USD'], 30, 3); ``` -#### `email(string $template, array $options = []): self` +#### `email(string $template, string $to, string $subject, array $data = []): self` Adds an email step to the workflow. ```php -$builder->email('welcome-email', [ - 'to' => '{{ user.email }}', - 'subject' => 'Welcome {{ user.name }}!', - 'data' => ['welcome_bonus' => 100] -]); +$builder->email('welcome-email', '{{ user.email }}', 'Welcome {{ user.name }}!', ['welcome_bonus' => 100]); ``` -#### `http(string $method, string $url, array $data = []): self` +#### `http(string $url, string $method = 'GET', array $data = [], array $headers = []): self` Adds an HTTP request step to the workflow. ```php -$builder->http('POST', 'https://api.example.com/webhooks', [ +$builder->http('https://api.example.com/webhooks', 'POST', [ 'event' => 'user_registered', 'user_id' => '{{ user.id }}' ]); @@ -61,26 +58,24 @@ Adds conditional logic to the workflow. ```php $builder->when('user.age >= 18', function($builder) { - $builder->step('verify-identity', VerifyIdentityAction::class); + $builder->addStep('verify-identity', VerifyIdentityAction::class); }); ``` -#### `retry(int $attempts, string $backoff = 'linear'): self` +#### `startWith(string $actionClass, array $config = [], ?int $timeout = null, int $retryAttempts = 0): self` -Configures retry behavior for the previous step. +Adds the first step in a workflow (syntactic sugar for better readability). ```php -$builder->step('api-call', ApiCallAction::class) - ->retry(attempts: 3, backoff: 'exponential'); +$builder->startWith(ValidateInputAction::class, ['strict' => true]); ``` -#### `timeout(int $seconds = null, int $minutes = null, int $hours = null): self` +#### `then(string $actionClass, array $config = [], ?int $timeout = null, int $retryAttempts = 0): self` -Sets a timeout for the previous step. +Adds a sequential step (syntactic sugar for better readability). ```php -$builder->step('long-operation', LongOperationAction::class) - ->timeout(minutes: 5); +$builder->then(ProcessDataAction::class)->then(SaveResultAction::class); ``` #### `build(): WorkflowDefinition` diff --git a/docs/getting-started.md b/docs/getting-started.md index 21440c5..e023443 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,10 +53,10 @@ use App\Actions\CreateUserProfileAction; // Create the workflow $registrationWorkflow = WorkflowBuilder::create('user-registration') - ->step('create-profile', CreateUserProfileAction::class) - ->email('welcome-email', to: '{{ user.email }}', subject: 'Welcome!') + ->addStep('create-profile', CreateUserProfileAction::class) + ->email('welcome-email', '{{ user.email }}', 'Welcome!') ->delay(hours: 24) - ->email('tips-email', to: '{{ user.email }}', subject: 'Getting Started Tips') + ->email('tips-email', '{{ user.email }}', 'Getting Started Tips') ->build(); // Start the workflow @@ -155,9 +155,7 @@ The workflow engine automatically handles errors and provides retry mechanisms: ```php $workflow = WorkflowBuilder::create('robust-workflow') - ->step('risky-operation', RiskyAction::class) - ->retry(attempts: 3, backoff: 'exponential') - ->timeout(seconds: 30) + ->addStep('risky-operation', RiskyAction::class, [], 30, 3) // timeout: 30s, retry: 3 attempts ->build(); ``` diff --git a/tests/Support/TestActions/CreateUserProfileAction.php b/tests/Support/TestActions/CreateUserProfileAction.php new file mode 100644 index 0000000..90dcad7 --- /dev/null +++ b/tests/Support/TestActions/CreateUserProfileAction.php @@ -0,0 +1,54 @@ +getData('user') ?? []; + + // Mock user profile creation + $profile = [ + 'id' => rand(1000, 9999), + 'user_id' => $userData['id'] ?? null, + 'name' => $userData['name'] ?? 'Unknown', + 'email' => $userData['email'] ?? 'unknown@example.com', + 'created_at' => date('Y-m-d H:i:s'), + ]; + + return ActionResult::success([ + 'profile_id' => $profile['id'], + 'profile' => $profile, + ]); + } + + public function canExecute(WorkflowContext $context): bool + { + return true; // Always executable for testing + } + + public function getName(): string + { + return 'Create User Profile'; + } + + public function getDescription(): string + { + return 'Creates a new user profile in the database'; + } +} diff --git a/tests/Support/TestActions/RiskyAction.php b/tests/Support/TestActions/RiskyAction.php new file mode 100644 index 0000000..4ba69ed --- /dev/null +++ b/tests/Support/TestActions/RiskyAction.php @@ -0,0 +1,42 @@ +getConfig(); + $failureRate = $config['failure_rate'] ?? 0.3; // 30% chance of failure by default + + // Simulate risky operation that sometimes fails + if (rand(1, 100) <= ($failureRate * 100)) { + throw new \Exception('Simulated failure in risky operation'); + } + + return ActionResult::success([ + 'operation_id' => 'risky_'.uniqid(), + 'success' => true, + 'executed_at' => date('Y-m-d H:i:s'), + ]); + } + + public function canExecute(WorkflowContext $context): bool + { + return true; // Always executable for testing + } + + public function getName(): string + { + return 'Risky Operation'; + } + + public function getDescription(): string + { + return 'A simulated risky operation that sometimes fails for testing retry logic'; + } +} diff --git a/tests/Support/TestActions/SendWelcomeEmailAction.php b/tests/Support/TestActions/SendWelcomeEmailAction.php new file mode 100644 index 0000000..6f97afd --- /dev/null +++ b/tests/Support/TestActions/SendWelcomeEmailAction.php @@ -0,0 +1,48 @@ +getConfig(); + $userData = $context->getData('user') ?? []; + + // Mock email sending + $emailData = [ + 'id' => 'email_'.uniqid(), + 'to' => $userData['email'] ?? 'unknown@example.com', + 'subject' => $config['subject'] ?? 'Welcome!', + 'template' => $config['template'] ?? 'welcome', + 'sent_at' => date('Y-m-d H:i:s'), + ]; + + return ActionResult::success([ + 'email_id' => $emailData['id'], + 'email_sent' => true, + 'email_data' => $emailData, + ]); + } + + public function canExecute(WorkflowContext $context): bool + { + $userData = $context->getData('user') ?? []; + + return ! empty($userData['email']); + } + + public function getName(): string + { + return 'Send Welcome Email'; + } + + public function getDescription(): string + { + return 'Sends a welcome email to the user'; + } +} diff --git a/tests/Support/TestActions/VerifyIdentityAction.php b/tests/Support/TestActions/VerifyIdentityAction.php new file mode 100644 index 0000000..12b5716 --- /dev/null +++ b/tests/Support/TestActions/VerifyIdentityAction.php @@ -0,0 +1,45 @@ +getData('user') ?? []; + + // Mock identity verification + $verificationResult = [ + 'verification_id' => 'verify_'.uniqid(), + 'user_id' => $userData['id'] ?? null, + 'status' => 'verified', + 'verified_at' => date('Y-m-d H:i:s'), + ]; + + return ActionResult::success([ + 'verification' => $verificationResult, + 'identity_verified' => true, + ]); + } + + public function canExecute(WorkflowContext $context): bool + { + $userData = $context->getData('user') ?? []; + + return isset($userData['id']) && ($userData['age'] ?? 0) >= 18; + } + + public function getName(): string + { + return 'Verify Identity'; + } + + public function getDescription(): string + { + return 'Verifies user identity for users 18 years and older'; + } +} diff --git a/tests/Unit/AdvancedFeaturesTest.php b/tests/Unit/AdvancedFeaturesTest.php new file mode 100644 index 0000000..96d5e06 --- /dev/null +++ b/tests/Unit/AdvancedFeaturesTest.php @@ -0,0 +1,224 @@ +engine = app(WorkflowEngine::class); + }); + + test('email action configuration matches documentation examples', function () { + $workflow = WorkflowBuilder::create('email-test') + ->email( + 'welcome-email', + '{{ user.email }}', + 'Welcome to {{ app.name }}!', + ['user_name' => '{{ user.name }}'] + ) + ->build(); + + $steps = $workflow->getSteps(); + $step = $steps['email_0']; + expect($step->getConfig())->toBe([ + 'template' => 'welcome-email', + 'to' => '{{ user.email }}', + 'subject' => 'Welcome to {{ app.name }}!', + 'data' => ['user_name' => '{{ user.name }}'], + ]); + }); + + test('delay action supports all time units from documentation', function () { + $workflow = WorkflowBuilder::create('delay-test') + ->delay(seconds: 30) // 30 second delay + ->delay(minutes: 5) // 5 minute delay + ->delay(hours: 1, minutes: 30) // 1.5 hour delay + ->build(); + + expect($workflow->getSteps())->toHaveCount(3); + + $steps = $workflow->getSteps(); + expect($steps['delay_0']->getConfig()['seconds'])->toBe(30); + expect($steps['delay_1']->getConfig()['seconds'])->toBe(300); + expect($steps['delay_2']->getConfig()['seconds'])->toBe(5400); // 1.5 hours + }); + + test('http action supports all documented parameters', function () { + $workflow = WorkflowBuilder::create('http-test') + ->http( + 'https://api.example.com/users', + 'POST', + ['name' => '{{ user.name }}', 'email' => '{{ user.email }}'], + ['Authorization' => 'Bearer {{ api.token }}'] + ) + ->build(); + + $steps = $workflow->getSteps(); + $stepId = array_keys($steps)[0]; // Get first step ID + $step = $steps[$stepId]; + expect($step->getConfig())->toBe([ + 'url' => 'https://api.example.com/users', + 'method' => 'POST', + 'data' => ['name' => '{{ user.name }}', 'email' => '{{ user.email }}'], + 'headers' => ['Authorization' => 'Bearer {{ api.token }}'], + ]); + }); + + test('conditional workflows work as documented', function () { + $workflow = WorkflowBuilder::create('conditional-test') + ->addStep('validate_order', CreateUserProfileAction::class) + ->when('user.age >= 18', function ($builder) { + $builder->addStep('verify_identity', VerifyIdentityAction::class); + $builder->addStep('premium_processing', SendWelcomeEmailAction::class); + }) + ->addStep('finalize_order', CreateUserProfileAction::class) + ->build(); + + expect($workflow->getSteps())->toHaveCount(4); + + // Check that conditional steps have the condition by step ID + $steps = $workflow->getSteps(); + $conditionalStep1 = $steps['verify_identity']; + $conditionalStep2 = $steps['premium_processing']; + + expect($conditionalStep1->getConditions())->toBe(['user.age >= 18']); + expect($conditionalStep2->getConditions())->toBe(['user.age >= 18']); + + // Non-conditional steps should not have condition + expect($steps['validate_order']->getConditions())->toBe([]); + expect($steps['finalize_order']->getConditions())->toBe([]); + }); + + test('workflow builder fluent interface supports method chaining', function () { + $workflow = WorkflowBuilder::create('chaining-test') + ->description('Test fluent interface') + ->version('1.5') + ->startWith(CreateUserProfileAction::class, ['profile_type' => 'basic']) + ->then(SendWelcomeEmailAction::class) + ->email('tips-email', '{{ user.email }}', 'Getting Started Tips') + ->delay(minutes: 5) + ->when('user.premium = true', function ($builder) { + $builder->then(VerifyIdentityAction::class); + }) + ->http('https://api.example.com/track', 'POST', ['user_id' => '{{ user.id }}']) + ->build(); + + expect($workflow->getName())->toBe('chaining-test'); + expect($workflow->getSteps())->toHaveCount(6); + + // Verify the chain of actions + $stepClasses = array_map(fn ($step) => $step->getActionClass(), $workflow->getSteps()); + expect($stepClasses)->toContain(CreateUserProfileAction::class); + expect($stepClasses)->toContain(SendWelcomeEmailAction::class); + expect($stepClasses)->toContain('SolutionForest\\WorkflowEngine\\Actions\\EmailAction'); + expect($stepClasses)->toContain('SolutionForest\\WorkflowEngine\\Actions\\DelayAction'); + expect($stepClasses)->toContain(VerifyIdentityAction::class); + expect($stepClasses)->toContain('SolutionForest\\WorkflowEngine\\Actions\\HttpAction'); + }); + + test('workflow with timeout and retry configuration works', function () { + $workflow = WorkflowBuilder::create('robust-test') + ->addStep('reliable_action', CreateUserProfileAction::class) + ->addStep('risky_action', RiskyAction::class, ['failure_rate' => 0.8], 60, 5) + ->addStep('final_action', SendWelcomeEmailAction::class, [], 30, 2) + ->build(); + + expect($workflow->getSteps())->toHaveCount(3); + + $steps = $workflow->getSteps(); + $reliableStep = $steps['reliable_action']; + expect($reliableStep->getTimeout())->toBeNull(); + expect($reliableStep->getRetryAttempts())->toBe(0); + + $riskyStep = $steps['risky_action']; + expect($riskyStep->getTimeout())->toBe('60'); + expect($riskyStep->getRetryAttempts())->toBe(5); + expect($riskyStep->getConfig()['failure_rate'])->toBe(0.8); + + $finalStep = $steps['final_action']; + expect($finalStep->getTimeout())->toBe('30'); + expect($finalStep->getRetryAttempts())->toBe(2); + }); + + test('complex workflow execution with all features', function () { + $workflow = WorkflowBuilder::create('integration-test') + ->startWith(CreateUserProfileAction::class, ['profile_type' => 'premium']) + ->email('welcome-email', '{{ user.email }}', 'Welcome Premium User!') + ->delay(seconds: 1) // Short delay for testing + ->when('user.age >= 21', function ($builder) { + $builder->addStep('age_verification', VerifyIdentityAction::class, [], '30s', 2); + }) + ->http('https://httpbin.org/post', 'POST', ['user_id' => '{{ user.id }}']) + ->then(SendWelcomeEmailAction::class, ['template' => 'completion']) + ->build(); + + // Convert to engine format and execute + $definition = $workflow->toArray(); + $workflowId = $this->engine->start('integration-test', $definition, [ + 'user' => [ + 'id' => 123, + 'email' => 'test@example.com', + 'age' => 25, + 'profile_type' => 'premium', + ], + ]); + + expect($workflowId)->not->toBeEmpty(); + + $instance = $this->engine->getInstance($workflowId); + expect($instance)->not->toBeNull(); + expect($instance->getContext()->getData('user')['id'])->toBe(123); + }); + + test('pre-built workflow patterns work correctly', function () { + // Test the userOnboarding pattern + $userWorkflow = WorkflowBuilder::quick()->userOnboarding('custom-onboarding'); + expect($userWorkflow)->toBeInstanceOf(WorkflowBuilder::class); + + $builtWorkflow = $userWorkflow->build(); + expect($builtWorkflow->getName())->toBe('custom-onboarding'); + expect($builtWorkflow->getSteps())->not->toBeEmpty(); + + // Test the orderProcessing pattern + $orderWorkflow = WorkflowBuilder::quick()->orderProcessing('custom-order'); + expect($orderWorkflow)->toBeInstanceOf(WorkflowBuilder::class); + + $builtOrderWorkflow = $orderWorkflow->build(); + expect($builtOrderWorkflow->getName())->toBe('custom-order'); + expect($builtOrderWorkflow->getSteps())->not->toBeEmpty(); + + // Test the documentApproval pattern + $docWorkflow = WorkflowBuilder::quick()->documentApproval('custom-approval'); + expect($docWorkflow)->toBeInstanceOf(WorkflowBuilder::class); + + $builtDocWorkflow = $docWorkflow->build(); + expect($builtDocWorkflow->getName())->toBe('custom-approval'); + expect($builtDocWorkflow->getSteps())->not->toBeEmpty(); + }); + + test('workflow validation catches invalid configurations', function () { + expect(function () { + WorkflowBuilder::create(''); // Empty name should fail + })->toThrow(\SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException::class); + + expect(function () { + WorkflowBuilder::create('123invalid'); // Invalid name format + })->toThrow(\SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException::class); + + expect(function () { + WorkflowBuilder::create('valid-name') + ->delay(); // No delay specified should fail + })->toThrow(\SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException::class); + + expect(function () { + WorkflowBuilder::create('valid-name') + ->when('', function ($builder) {}); // Empty condition should fail + })->toThrow(\SolutionForest\WorkflowEngine\Exceptions\InvalidWorkflowDefinitionException::class); + }); + +}); diff --git a/tests/Unit/AttributesTest.php b/tests/Unit/AttributesTest.php new file mode 100644 index 0000000..3358887 --- /dev/null +++ b/tests/Unit/AttributesTest.php @@ -0,0 +1,192 @@ +getAttributes(WorkflowStep::class); + + expect($attributes)->toHaveCount(1); + + $workflowStep = $attributes[0]->newInstance(); + expect($workflowStep->id)->toBe('create_profile'); + expect($workflowStep->name)->toBe('Create User Profile'); + expect($workflowStep->description)->toBe('Creates a new user profile in the database'); + expect($workflowStep->required)->toBe(true); + expect($workflowStep->order)->toBe(0); + }); + + test('Timeout attribute works correctly', function () { + $reflection = new ReflectionClass(CreateUserProfileAction::class); + $attributes = $reflection->getAttributes(Timeout::class); + + expect($attributes)->toHaveCount(1); + + $timeout = $attributes[0]->newInstance(); + expect($timeout->totalSeconds)->toBe(30); + }); + + test('Retry attribute works correctly', function () { + $reflection = new ReflectionClass(CreateUserProfileAction::class); + $attributes = $reflection->getAttributes(Retry::class); + + expect($attributes)->toHaveCount(1); + + $retry = $attributes[0]->newInstance(); + expect($retry->attempts)->toBe(3); + expect($retry->backoff)->toBe('exponential'); + expect($retry->delay)->toBe(1000); + expect($retry->maxDelay)->toBe(30000); + }); + + test('attributes can be used for action configuration', function () { + $actionClass = CreateUserProfileAction::class; + $reflection = new ReflectionClass($actionClass); + + // Extract configuration from attributes + $config = []; + + // WorkflowStep attribute + $stepAttributes = $reflection->getAttributes(WorkflowStep::class); + if (! empty($stepAttributes)) { + $step = $stepAttributes[0]->newInstance(); + $config['step_id'] = $step->id; + $config['step_name'] = $step->name; + $config['step_description'] = $step->description; + $config['required'] = $step->required; + } + + // Timeout attribute + $timeoutAttributes = $reflection->getAttributes(Timeout::class); + if (! empty($timeoutAttributes)) { + $timeout = $timeoutAttributes[0]->newInstance(); + $config['timeout'] = $timeout->totalSeconds; + } + + // Retry attribute + $retryAttributes = $reflection->getAttributes(Retry::class); + if (! empty($retryAttributes)) { + $retry = $retryAttributes[0]->newInstance(); + $config['retry_attempts'] = $retry->attempts; + $config['retry_backoff'] = $retry->backoff; + $config['retry_delay'] = $retry->delay; + } + + expect($config)->toBe([ + 'step_id' => 'create_profile', + 'step_name' => 'Create User Profile', + 'step_description' => 'Creates a new user profile in the database', + 'required' => true, + 'timeout' => 30, + 'retry_attempts' => 3, + 'retry_backoff' => 'exponential', + 'retry_delay' => 1000, + ]); + }); + + test('action without attributes still works', function () { + $reflection = new ReflectionClass(VerifyIdentityAction::class); + + $stepAttributes = $reflection->getAttributes(WorkflowStep::class); + $timeoutAttributes = $reflection->getAttributes(Timeout::class); + $retryAttributes = $reflection->getAttributes(Retry::class); + + expect($stepAttributes)->toBeEmpty(); + expect($timeoutAttributes)->toBeEmpty(); + expect($retryAttributes)->toBeEmpty(); + + // Action should still be instantiable and executable + $action = new VerifyIdentityAction; + expect($action->getName())->toBe('Verify Identity'); + expect($action->getDescription())->toBe('Verifies user identity for users 18 years and older'); + }); + + test('multiple attributes can be combined on a single action', function () { + // Create a test action with multiple attributes + $testActionCode = ' 100", operator: "and")] + class MultiAttributeTestAction implements WorkflowAction + { + public function execute(WorkflowContext $context): ActionResult { + return ActionResult::success(["executed" => true]); + } + public function canExecute(WorkflowContext $context): bool { return true; } + public function getName(): string { return "Multi Attribute Action"; } + public function getDescription(): string { return "Test action with multiple attributes"; } + }'; + + // Temporarily create the class in memory for testing + eval(substr($testActionCode, 5)); // Remove opening getAttributes(WorkflowStep::class); + expect($stepAttrs)->toHaveCount(1); + $step = $stepAttrs[0]->newInstance(); + expect($step->id)->toBe('multi_attr'); + + // Check Timeout with combined time units + $timeoutAttrs = $reflection->getAttributes(Timeout::class); + expect($timeoutAttrs)->toHaveCount(1); + $timeout = $timeoutAttrs[0]->newInstance(); + expect($timeout->totalSeconds)->toBe(330); // 5 minutes + 30 seconds = 330 seconds + + // Check Retry with custom configuration + $retryAttrs = $reflection->getAttributes(Retry::class); + expect($retryAttrs)->toHaveCount(1); + $retry = $retryAttrs[0]->newInstance(); + expect($retry->attempts)->toBe(5); + expect($retry->backoff)->toBe('exponential'); + expect($retry->delay)->toBe(2000); + expect($retry->maxDelay)->toBe(60000); + + // Check multiple Condition attributes + $conditionAttrs = $reflection->getAttributes(Condition::class); + expect($conditionAttrs)->toHaveCount(2); + + $condition1 = $conditionAttrs[0]->newInstance(); + expect($condition1->expression)->toBe('user.premium = true'); + + $condition2 = $conditionAttrs[1]->newInstance(); + expect($condition2->expression)->toBe('order.amount > 100'); + expect($condition2->operator)->toBe('and'); + }); + + test('attribute inheritance works correctly', function () { + // Test that attributes can be read from parent classes if needed + $reflection = new ReflectionClass(CreateUserProfileAction::class); + + // Get all attributes including inherited ones + $allAttributes = []; + foreach ($reflection->getAttributes() as $attribute) { + $allAttributes[] = $attribute->getName(); + } + + expect($allAttributes)->toContain(WorkflowStep::class); + expect($allAttributes)->toContain(Timeout::class); + expect($allAttributes)->toContain(Retry::class); + }); + +}); diff --git a/tests/Unit/DocumentationExamplesTest.php b/tests/Unit/DocumentationExamplesTest.php new file mode 100644 index 0000000..fd032d5 --- /dev/null +++ b/tests/Unit/DocumentationExamplesTest.php @@ -0,0 +1,260 @@ +engine = app(WorkflowEngine::class); + }); + + test('getting started - basic workflow creation works', function () { + $registrationWorkflow = WorkflowBuilder::create('user-registration') + ->addStep('create-profile', CreateUserProfileAction::class) + ->email('welcome-email', '{{ user.email }}', 'Welcome!') + ->delay(hours: 24) + ->email('tips-email', '{{ user.email }}', 'Getting Started Tips') + ->build(); + + expect($registrationWorkflow)->not->toBeNull(); + expect($registrationWorkflow->getName())->toBe('user-registration'); + expect($registrationWorkflow->getSteps())->toHaveCount(4); + }); + + test('getting started - workflow execution works', function () { + $registrationWorkflow = WorkflowBuilder::create('user-registration') + ->addStep('create-profile', CreateUserProfileAction::class) + ->build(); + + $user = ['id' => 1, 'email' => 'test@example.com', 'name' => 'John Doe']; + $registrationData = ['source' => 'web']; + + // Convert WorkflowDefinition to array format for engine + $definition = $registrationWorkflow->toArray(); + $workflowId = $this->engine->start('user-registration', $definition, [ + 'user' => $user, + 'registration_data' => $registrationData, + ]); + + expect($workflowId)->not->toBeEmpty(); + + $instance = $this->engine->getInstance($workflowId); + $context = $instance->getContext(); + + // Direct test of the getData method + $contextClass = get_class($context); + expect($contextClass)->toBe(\SolutionForest\WorkflowEngine\Core\WorkflowContext::class); + + // Test the getData method implementation directly + $allData = $context->getData(); + expect($allData)->toHaveKey('user'); + expect($allData)->toHaveKey('registration_data'); + + $userData = $context->getData('user'); + $registrationData = $context->getData('registration_data'); + + expect($userData)->toBe($user); + expect($registrationData)->toBe($registrationData); + + // Check that the action added new data + expect($context->hasData('profile_id'))->toBe(true); + expect($context->hasData('profile'))->toBe(true); + }); + + test('getting started - workflow states work correctly', function () { + $workflow = WorkflowBuilder::create('test-workflow') + ->addStep('test-action', CreateUserProfileAction::class) + ->build(); + + $definition = $workflow->toArray(); + $workflowId = $this->engine->start('test-workflow', $definition, ['test' => 'data']); + $instance = $this->engine->getInstance($workflowId); + $state = $instance->getState(); + + expect($state)->toBeInstanceOf(WorkflowState::class); + expect(in_array($state->value, ['running', 'completed', 'failed', 'paused']))->toBeTrue(); + expect($state->label())->not->toBeEmpty(); + expect($state->color())->not->toBeEmpty(); + expect($state->icon())->not->toBeEmpty(); + }); + + test('getting started - error handling with timeout and retry works', function () { + $workflow = WorkflowBuilder::create('robust-workflow') + ->addStep('risky-operation', RiskyAction::class, [], '30s', 3) // timeout: 30s, retry: 3 attempts + ->build(); + + expect($workflow)->not->toBeNull(); + + // Debug: check steps count and details + $steps = $workflow->getSteps(); + expect($steps)->toHaveCount(1); + + $step = $steps['risky-operation']; + expect($step->getTimeout())->toBe('30s'); // Should remain as string format + expect($step->getRetryAttempts())->toBe(3); + }); + + test('api reference - addStep method works', function () { + $builder = WorkflowBuilder::create('payment-workflow'); + + // Basic step + $builder->addStep('process-payment', ProcessPaymentAction::class); + + // Step with config, timeout, and retry + $builder->addStep('process-payment-complex', ProcessPaymentAction::class, ['currency' => 'USD'], '30s', 3); + + $workflow = $builder->build(); + + expect($workflow->getSteps())->toHaveCount(2); + + // Access steps by ID instead of numeric index + $steps = $workflow->getSteps(); + + $basicStep = $steps['process-payment']; + expect($basicStep->getId())->toBe('process-payment'); + expect($basicStep->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Actions\\ECommerce\\ProcessPaymentAction'); + + $complexStep = $steps['process-payment-complex']; + expect($complexStep->getId())->toBe('process-payment-complex'); + expect($complexStep->getConfig()['currency'])->toBe('USD'); + }); + + test('api reference - email method works', function () { + $workflow = WorkflowBuilder::create('email-workflow') + ->email('welcome-email', '{{ user.email }}', 'Welcome {{ user.name }}!', ['welcome_bonus' => 100]) + ->build(); + + expect($workflow->getSteps())->toHaveCount(1); + + $steps = $workflow->getSteps(); + $step = $steps['email_0']; + expect($step->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Actions\\EmailAction'); + expect($step->getConfig()['template'])->toBe('welcome-email'); + expect($step->getConfig()['to'])->toBe('{{ user.email }}'); + expect($step->getConfig()['subject'])->toBe('Welcome {{ user.name }}!'); + expect($step->getConfig()['data'])->toBe(['welcome_bonus' => 100]); + }); + + test('api reference - http method works', function () { + $workflow = WorkflowBuilder::create('http-workflow') + ->http('https://api.example.com/webhooks', 'POST', [ + 'event' => 'user_registered', + 'user_id' => '{{ user.id }}', + ]) + ->build(); + + expect($workflow->getSteps())->toHaveCount(1); + + $steps = $workflow->getSteps(); + $step = $steps['http_0']; + expect($step->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Actions\\HttpAction'); + expect($step->getConfig()['url'])->toBe('https://api.example.com/webhooks'); + expect($step->getConfig()['method'])->toBe('POST'); + expect($step->getConfig()['data'])->toBe([ + 'event' => 'user_registered', + 'user_id' => '{{ user.id }}', + ]); + }); + + test('api reference - delay method works', function () { + $workflow = WorkflowBuilder::create('delay-workflow') + ->delay(minutes: 30) + ->delay(hours: 2) + ->delay(hours: 24) // 1 day equivalent + ->build(); + + expect($workflow->getSteps())->toHaveCount(3); + + $steps = $workflow->getSteps(); + + // 30 minutes = 1800 seconds + expect($steps['delay_0']->getConfig()['seconds'])->toBe(1800); + + // 2 hours = 7200 seconds + expect($steps['delay_1']->getConfig()['seconds'])->toBe(7200); + + // 24 hours = 86400 seconds + expect($steps['delay_2']->getConfig()['seconds'])->toBe(86400); + }); + + test('api reference - when conditional method works', function () { + $workflow = WorkflowBuilder::create('conditional-workflow') + ->addStep('validate-user', CreateUserProfileAction::class) + ->when('user.age >= 18', function ($builder) { + $builder->addStep('verify-identity', VerifyIdentityAction::class); + }) + ->build(); + + expect($workflow->getSteps())->toHaveCount(2); + + $steps = $workflow->getSteps(); + $conditionalStep = $steps['verify-identity']; + expect($conditionalStep->getId())->toBe('verify-identity'); + expect($conditionalStep->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Support\\TestActions\\VerifyIdentityAction'); + expect($conditionalStep->getConditions())->toBe(['user.age >= 18']); + }); + + test('api reference - startWith method works', function () { + $workflow = WorkflowBuilder::create('start-workflow') + ->startWith(CreateUserProfileAction::class, ['strict' => true]) + ->build(); + + expect($workflow->getSteps())->toHaveCount(1); + + $steps = $workflow->getSteps(); + $step = $steps['step_1']; + expect($step->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Support\\TestActions\\CreateUserProfileAction'); + expect($step->getConfig()['strict'])->toBe(true); + }); + + test('api reference - then method works', function () { + $workflow = WorkflowBuilder::create('chain-workflow') + ->startWith(CreateUserProfileAction::class) + ->then(SendWelcomeEmailAction::class) + ->then(VerifyIdentityAction::class) + ->build(); + + expect($workflow->getSteps())->toHaveCount(3); + + $steps = $workflow->getSteps(); + expect($steps['step_1']->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Support\\TestActions\\CreateUserProfileAction'); + expect($steps['step_2']->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Support\\TestActions\\SendWelcomeEmailAction'); + expect($steps['step_3']->getActionClass())->toBe('SolutionForest\\WorkflowEngine\\Laravel\\Tests\\Support\\TestActions\\VerifyIdentityAction'); + }); + + test('complex workflow combining multiple features works', function () { + $workflow = WorkflowBuilder::create('complex-workflow') + ->description('A complex workflow showcasing all features') + ->version('2.0') + ->startWith(CreateUserProfileAction::class, ['profile_type' => 'premium']) + ->email('welcome-email', '{{ user.email }}', 'Welcome to Premium!') + ->when('user.age >= 21', function ($builder) { + $builder->addStep('age-verification', VerifyIdentityAction::class, [], '60s', 2); + }) + ->delay(minutes: 5) + ->http('https://api.example.com/notify', 'POST', ['user_id' => '{{ user.id }}']) + ->then(ProcessPaymentAction::class, ['amount' => 99.99], '120s', 3) + ->build(); + + expect($workflow->getName())->toBe('complex-workflow'); + expect($workflow->getSteps())->toHaveCount(6); // startWith + email + conditional + delay + http + then + + // Test execution + $definition = $workflow->toArray(); + $workflowId = $this->engine->start('complex-workflow', $definition, [ + 'user' => ['id' => 1, 'email' => 'test@example.com', 'age' => 25], + ]); + + expect($workflowId)->not->toBeEmpty(); + $instance = $this->engine->getInstance($workflowId); + expect($instance)->not->toBeNull(); + }); + +});