From 6ff7f78c9c460296fdd21ad29e75b9cd0bd89ff5 Mon Sep 17 00:00:00 2001 From: m105 Date: Thu, 6 Aug 2026 20:38:52 +0300 Subject: [PATCH] Add Video Projects schema and workflow engine core (tasks #31, #32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational layer for the YouTube Content Creation System project, built as a fully independent domain per the earlier decision (task #47) — no foreign keys into the existing projects/tasks/ideas tables. Schema: video_projects, workflow_steps, assets, ai_providers, workflow_templates (named to avoid colliding with the existing Post-templates `templates` table). WorkflowEngine (app/Support/VideoWorkflow/) drives a project's step sequence: start/startFromTemplate, currentStep, completeStep (with optional approval gating), approveStep/rejectStep, failStep/retryStep, skipStep. It only manages state transitions — actual step execution (AI calls, rendering, etc.) is out of scope here (tasks #33+). Also fixes four Publisher classes (WordPress/Reddit/Twitter/LinkedIn) that were missing a `use App\Models\SocialAccount;` import after an earlier interface change, which broke the entire test suite at bootstrap; and PostFactory, which still set the now-removed `social_account_id` column instead of the SocialAccount pivot. Co-Authored-By: Claude Sonnet 5 --- app/Models/AiProvider.php | 26 ++ app/Models/Asset.php | 39 +++ app/Models/VideoProject.php | 38 +++ app/Models/WorkflowStep.php | 62 +++++ app/Models/WorkflowTemplate.php | 27 ++ app/Support/Publishing/LinkedInPublisher.php | 1 + app/Support/Publishing/RedditPublisher.php | 1 + app/Support/Publishing/TwitterPublisher.php | 1 + app/Support/Publishing/WordPressPublisher.php | 1 + app/Support/VideoWorkflow/WorkflowEngine.php | 191 +++++++++++++ .../WorkflowTransitionException.php | 7 + database/factories/AiProviderFactory.php | 21 ++ database/factories/AssetFactory.php | 24 ++ database/factories/PostFactory.php | 1 - database/factories/VideoProjectFactory.php | 20 ++ database/factories/WorkflowStepFactory.php | 23 ++ .../factories/WorkflowTemplateFactory.php | 24 ++ ...203010_create_workflow_templates_table.php | 32 +++ ..._06_203011_create_video_projects_table.php | 36 +++ ..._06_203012_create_workflow_steps_table.php | 39 +++ .../2026_08_06_203013_create_assets_table.php | 35 +++ ...08_06_203014_create_ai_providers_table.php | 32 +++ .../Publishing/RedditPublisherTest.php | 38 +-- .../VideoWorkflow/VideoWorkflowModelsTest.php | 98 +++++++ .../VideoWorkflow/WorkflowEngineTest.php | 257 ++++++++++++++++++ 25 files changed, 1049 insertions(+), 25 deletions(-) create mode 100644 app/Models/AiProvider.php create mode 100644 app/Models/Asset.php create mode 100644 app/Models/VideoProject.php create mode 100644 app/Models/WorkflowStep.php create mode 100644 app/Models/WorkflowTemplate.php create mode 100644 app/Support/VideoWorkflow/WorkflowEngine.php create mode 100644 app/Support/VideoWorkflow/WorkflowTransitionException.php create mode 100644 database/factories/AiProviderFactory.php create mode 100644 database/factories/AssetFactory.php create mode 100644 database/factories/VideoProjectFactory.php create mode 100644 database/factories/WorkflowStepFactory.php create mode 100644 database/factories/WorkflowTemplateFactory.php create mode 100644 database/migrations/2026_08_06_203010_create_workflow_templates_table.php create mode 100644 database/migrations/2026_08_06_203011_create_video_projects_table.php create mode 100644 database/migrations/2026_08_06_203012_create_workflow_steps_table.php create mode 100644 database/migrations/2026_08_06_203013_create_assets_table.php create mode 100644 database/migrations/2026_08_06_203014_create_ai_providers_table.php create mode 100644 tests/Feature/VideoWorkflow/VideoWorkflowModelsTest.php create mode 100644 tests/Feature/VideoWorkflow/WorkflowEngineTest.php diff --git a/app/Models/AiProvider.php b/app/Models/AiProvider.php new file mode 100644 index 0000000..c73e861 --- /dev/null +++ b/app/Models/AiProvider.php @@ -0,0 +1,26 @@ + 'array', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/Asset.php b/app/Models/Asset.php new file mode 100644 index 0000000..aa3228c --- /dev/null +++ b/app/Models/Asset.php @@ -0,0 +1,39 @@ + 'array', + ]; + + public function videoProject(): BelongsTo + { + return $this->belongsTo(VideoProject::class); + } + + public function workflowStep(): BelongsTo + { + return $this->belongsTo(WorkflowStep::class); + } +} diff --git a/app/Models/VideoProject.php b/app/Models/VideoProject.php new file mode 100644 index 0000000..082442f --- /dev/null +++ b/app/Models/VideoProject.php @@ -0,0 +1,38 @@ +belongsTo(WorkflowTemplate::class); + } + + public function steps(): HasMany + { + return $this->hasMany(WorkflowStep::class)->orderBy('order'); + } + + public function assets(): HasMany + { + return $this->hasMany(Asset::class); + } +} diff --git a/app/Models/WorkflowStep.php b/app/Models/WorkflowStep.php new file mode 100644 index 0000000..0fa0afa --- /dev/null +++ b/app/Models/WorkflowStep.php @@ -0,0 +1,62 @@ + 'boolean', + 'input' => 'array', + 'output' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function videoProject(): BelongsTo + { + return $this->belongsTo(VideoProject::class); + } + + public function assets(): HasMany + { + return $this->hasMany(Asset::class); + } +} diff --git a/app/Models/WorkflowTemplate.php b/app/Models/WorkflowTemplate.php new file mode 100644 index 0000000..89cfd15 --- /dev/null +++ b/app/Models/WorkflowTemplate.php @@ -0,0 +1,27 @@ + 'array', + ]; + + public function videoProjects(): HasMany + { + return $this->hasMany(VideoProject::class); + } +} diff --git a/app/Support/Publishing/LinkedInPublisher.php b/app/Support/Publishing/LinkedInPublisher.php index c3f7009..c70e069 100644 --- a/app/Support/Publishing/LinkedInPublisher.php +++ b/app/Support/Publishing/LinkedInPublisher.php @@ -3,6 +3,7 @@ namespace App\Support\Publishing; use App\Models\Post; +use App\Models\SocialAccount; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; diff --git a/app/Support/Publishing/RedditPublisher.php b/app/Support/Publishing/RedditPublisher.php index 37e6099..35ce86b 100644 --- a/app/Support/Publishing/RedditPublisher.php +++ b/app/Support/Publishing/RedditPublisher.php @@ -3,6 +3,7 @@ namespace App\Support\Publishing; use App\Models\Post; +use App\Models\SocialAccount; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; diff --git a/app/Support/Publishing/TwitterPublisher.php b/app/Support/Publishing/TwitterPublisher.php index 2206256..0227830 100644 --- a/app/Support/Publishing/TwitterPublisher.php +++ b/app/Support/Publishing/TwitterPublisher.php @@ -3,6 +3,7 @@ namespace App\Support\Publishing; use App\Models\Post; +use App\Models\SocialAccount; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; diff --git a/app/Support/Publishing/WordPressPublisher.php b/app/Support/Publishing/WordPressPublisher.php index b43a9d2..95f6187 100644 --- a/app/Support/Publishing/WordPressPublisher.php +++ b/app/Support/Publishing/WordPressPublisher.php @@ -4,6 +4,7 @@ namespace App\Support\Publishing; use App\Models\Post; use App\Models\Site; +use App\Models\SocialAccount; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; diff --git a/app/Support/VideoWorkflow/WorkflowEngine.php b/app/Support/VideoWorkflow/WorkflowEngine.php new file mode 100644 index 0000000..351b253 --- /dev/null +++ b/app/Support/VideoWorkflow/WorkflowEngine.php @@ -0,0 +1,191 @@ + in_progress -> awaiting_approval -> completed + * \-> completed (skips approval if not required) + * \-> failed -> in_progress (retry) + * awaiting_approval -> rejected -> in_progress (retry) + * pending | in_progress -> skipped + * + * This class only manages state — it doesn't know how to actually generate + * a script, render video, etc. Whatever does that work (a future AI + * provider integration, a human, an Artisan command) calls completeStep()/ + * failStep() to report the result. + */ +class WorkflowEngine +{ + /** + * Create the step sequence for a project and activate the first one. + * + * @param array $steps + */ + public function start(VideoProject $project, array $steps): WorkflowStep + { + if ($project->steps()->exists()) { + throw new WorkflowTransitionException("Video project {$project->id} already has workflow steps."); + } + + if (empty($steps)) { + throw new WorkflowTransitionException('Cannot start a workflow with no steps.'); + } + + foreach (array_values($steps) as $order => $step) { + $project->steps()->create([ + 'type' => $step['type'], + 'order' => $order, + 'status' => 'pending', + 'requires_approval' => $step['requires_approval'] ?? false, + ]); + } + + $project->update(['status' => 'in_progress']); + + $first = $project->steps()->orderBy('order')->firstOrFail(); + + return $this->activate($first); + } + + public function startFromTemplate(VideoProject $project, WorkflowTemplate $template): WorkflowStep + { + $project->update(['workflow_template_id' => $template->id]); + + return $this->start($project, $template->steps); + } + + /** + * The step currently blocking progress — pending, running, awaiting + * approval, or stuck on failure/rejection. Null once every step is + * completed or skipped. + */ + public function currentStep(VideoProject $project): ?WorkflowStep + { + return $project->steps() + ->whereNotIn('status', ['completed', 'skipped']) + ->orderBy('order') + ->first(); + } + + public function completeStep(WorkflowStep $step, array $output = []): WorkflowStep + { + $this->assertStatus($step, 'in_progress'); + + $step->update([ + 'output' => $output, + 'error' => null, + 'completed_at' => now(), + 'status' => $step->requires_approval ? 'awaiting_approval' : 'completed', + ]); + + if ($step->status === 'completed') { + $this->activateNext($step); + } + + return $step->fresh(); + } + + public function approveStep(WorkflowStep $step): WorkflowStep + { + $this->assertStatus($step, 'awaiting_approval'); + + $step->update(['status' => 'completed']); + + $this->activateNext($step); + + return $step->fresh(); + } + + /** + * Rejecting does not advance the pipeline — it stays blocked on this + * step until retryStep() is called. + */ + public function rejectStep(WorkflowStep $step, ?string $reason = null): WorkflowStep + { + $this->assertStatus($step, 'awaiting_approval'); + + $step->update(['status' => 'rejected', 'error' => $reason]); + + return $step->fresh(); + } + + public function failStep(WorkflowStep $step, string $error): WorkflowStep + { + $this->assertStatus($step, 'in_progress'); + + $step->update(['status' => 'failed', 'error' => $error]); + + return $step->fresh(); + } + + public function retryStep(WorkflowStep $step): WorkflowStep + { + if (! in_array($step->status, ['failed', 'rejected'], true)) { + throw new WorkflowTransitionException("Step {$step->id} can only be retried from failed or rejected, currently '{$step->status}'."); + } + + $step->update([ + 'status' => 'in_progress', + 'error' => null, + 'output' => null, + 'started_at' => now(), + 'completed_at' => null, + ]); + + return $step->fresh(); + } + + public function skipStep(WorkflowStep $step): WorkflowStep + { + if (! in_array($step->status, ['pending', 'in_progress'], true)) { + throw new WorkflowTransitionException("Step {$step->id} can only be skipped from pending or in_progress, currently '{$step->status}'."); + } + + $step->update(['status' => 'skipped', 'completed_at' => now()]); + + $this->activateNext($step); + + return $step->fresh(); + } + + private function activate(WorkflowStep $step): WorkflowStep + { + $step->update(['status' => 'in_progress', 'started_at' => now()]); + + return $step->fresh(); + } + + private function activateNext(WorkflowStep $justFinished): void + { + $project = $justFinished->videoProject; + + $next = $project->steps() + ->where('order', '>', $justFinished->order) + ->whereNotIn('status', ['completed', 'skipped']) + ->orderBy('order') + ->first(); + + if ($next) { + $this->activate($next); + + return; + } + + $project->update(['status' => 'completed']); + } + + private function assertStatus(WorkflowStep $step, string $expected): void + { + if ($step->status !== $expected) { + throw new WorkflowTransitionException("Step {$step->id} must be '{$expected}' for this transition, currently '{$step->status}'."); + } + } +} diff --git a/app/Support/VideoWorkflow/WorkflowTransitionException.php b/app/Support/VideoWorkflow/WorkflowTransitionException.php new file mode 100644 index 0000000..76900e9 --- /dev/null +++ b/app/Support/VideoWorkflow/WorkflowTransitionException.php @@ -0,0 +1,7 @@ + fake()->company().' Account', + 'provider' => fake()->randomElement(AiProvider::PROVIDERS), + 'api_key' => fake()->uuid(), + 'is_active' => true, + ]; + } +} diff --git a/database/factories/AssetFactory.php b/database/factories/AssetFactory.php new file mode 100644 index 0000000..e2618db --- /dev/null +++ b/database/factories/AssetFactory.php @@ -0,0 +1,24 @@ + VideoProject::factory(), + 'type' => fake()->randomElement(Asset::TYPES), + 'disk_path' => 'video-projects/'.fake()->uuid().'.bin', + 'mime_type' => 'application/octet-stream', + 'size_bytes' => fake()->numberBetween(1000, 5_000_000), + 'version' => 1, + ]; + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index 6690061..51c6b87 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -14,7 +14,6 @@ class PostFactory extends Factory { return [ 'site_id' => Site::factory(), - 'social_account_id' => null, 'type' => fake()->randomElement(Post::TYPES), 'title' => fake()->optional()->sentence(), 'body' => fake()->paragraph(), diff --git a/database/factories/VideoProjectFactory.php b/database/factories/VideoProjectFactory.php new file mode 100644 index 0000000..bf605d5 --- /dev/null +++ b/database/factories/VideoProjectFactory.php @@ -0,0 +1,20 @@ + fake()->sentence(4), + 'description' => fake()->optional()->paragraph(), + 'status' => 'draft', + ]; + } +} diff --git a/database/factories/WorkflowStepFactory.php b/database/factories/WorkflowStepFactory.php new file mode 100644 index 0000000..9b8da59 --- /dev/null +++ b/database/factories/WorkflowStepFactory.php @@ -0,0 +1,23 @@ + VideoProject::factory(), + 'type' => fake()->randomElement(WorkflowStep::TYPES), + 'order' => 0, + 'status' => 'pending', + 'requires_approval' => false, + ]; + } +} diff --git a/database/factories/WorkflowTemplateFactory.php b/database/factories/WorkflowTemplateFactory.php new file mode 100644 index 0000000..413b387 --- /dev/null +++ b/database/factories/WorkflowTemplateFactory.php @@ -0,0 +1,24 @@ + fake()->sentence(3), + 'description' => fake()->optional()->sentence(), + 'steps' => [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover', 'requires_approval' => false], + ['type' => 'video_generation', 'requires_approval' => true], + ], + ]; + } +} diff --git a/database/migrations/2026_08_06_203010_create_workflow_templates_table.php b/database/migrations/2026_08_06_203010_create_workflow_templates_table.php new file mode 100644 index 0000000..772a82a --- /dev/null +++ b/database/migrations/2026_08_06_203010_create_workflow_templates_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + // Ordered array of step definitions, e.g. + // [{"type": "script_generation", "requires_approval": true}, ...] + $table->json('steps'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workflow_templates'); + } +}; diff --git a/database/migrations/2026_08_06_203011_create_video_projects_table.php b/database/migrations/2026_08_06_203011_create_video_projects_table.php new file mode 100644 index 0000000..f9d2778 --- /dev/null +++ b/database/migrations/2026_08_06_203011_create_video_projects_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('draft'); + $table->foreignId('workflow_template_id')->nullable()->constrained()->nullOnDelete(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('video_projects'); + } +}; diff --git a/database/migrations/2026_08_06_203012_create_workflow_steps_table.php b/database/migrations/2026_08_06_203012_create_workflow_steps_table.php new file mode 100644 index 0000000..ca72246 --- /dev/null +++ b/database/migrations/2026_08_06_203012_create_workflow_steps_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('video_project_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->unsignedInteger('order'); + $table->string('status')->default('pending'); + $table->boolean('requires_approval')->default(false); + $table->json('input')->nullable(); + $table->json('output')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->unique(['video_project_id', 'order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('workflow_steps'); + } +}; diff --git a/database/migrations/2026_08_06_203013_create_assets_table.php b/database/migrations/2026_08_06_203013_create_assets_table.php new file mode 100644 index 0000000..e9d602a --- /dev/null +++ b/database/migrations/2026_08_06_203013_create_assets_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('video_project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('workflow_step_id')->nullable()->constrained()->nullOnDelete(); + $table->string('type'); + $table->string('disk_path'); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->unsignedInteger('version')->default(1); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('assets'); + } +}; diff --git a/database/migrations/2026_08_06_203014_create_ai_providers_table.php b/database/migrations/2026_08_06_203014_create_ai_providers_table.php new file mode 100644 index 0000000..8611555 --- /dev/null +++ b/database/migrations/2026_08_06_203014_create_ai_providers_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('provider'); + $table->string('api_key'); + $table->json('config')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_providers'); + } +}; diff --git a/tests/Feature/Support/Publishing/RedditPublisherTest.php b/tests/Feature/Support/Publishing/RedditPublisherTest.php index 1debb1b..e5ea9c5 100644 --- a/tests/Feature/Support/Publishing/RedditPublisherTest.php +++ b/tests/Feature/Support/Publishing/RedditPublisherTest.php @@ -13,7 +13,8 @@ test('it supports reddit posts only', function () { }); test('it rejects a post with no title', function () { - $post = Post::factory()->for(SocialAccount::factory()->state(['network' => 'reddit']), 'socialAccount') + $post = Post::factory() + ->hasAttached(SocialAccount::factory()->state(['network' => 'reddit']), [], 'socialAccounts') ->create(['type' => 'reddit', 'title' => null, 'target' => 'test']); $result = (new RedditPublisher)->publish($post); @@ -24,7 +25,8 @@ test('it rejects a post with no title', function () { }); test('it rejects a post with no target subreddit', function () { - $post = Post::factory()->for(SocialAccount::factory()->state(['network' => 'reddit']), 'socialAccount') + $post = Post::factory() + ->hasAttached(SocialAccount::factory()->state(['network' => 'reddit']), [], 'socialAccounts') ->create(['type' => 'reddit', 'title' => 'Hi', 'target' => null]); $result = (new RedditPublisher)->publish($post); @@ -35,7 +37,7 @@ test('it rejects a post with no target subreddit', function () { }); test('it rejects a post with no linked reddit account', function () { - $post = Post::factory()->create(['type' => 'reddit', 'title' => 'Hi', 'target' => 'test', 'social_account_id' => null]); + $post = Post::factory()->create(['type' => 'reddit', 'title' => 'Hi', 'target' => 'test']); $result = (new RedditPublisher)->publish($post); @@ -50,14 +52,10 @@ test('it publishes successfully via the sidecar', function () { ]); $account = SocialAccount::factory()->create(['network' => 'reddit', 'username' => 'redditor']); - $post = Post::factory()->create([ - 'type' => 'reddit', - 'title' => 'Hi', - 'target' => 'test', - 'social_account_id' => $account->id, - ]); + $post = Post::factory()->create(['type' => 'reddit', 'title' => 'Hi', 'target' => 'test']); + $post->socialAccounts()->attach($account); - $result = (new RedditPublisher)->publish($post); + $result = (new RedditPublisher)->publish($post->fresh()); expect($result->success)->toBeTrue(); expect($result->url)->toBe('https://reddit.com/r/test/comments/abc123'); @@ -75,14 +73,10 @@ test('it treats a sidecar-reported failure as an attempted, failed publish', fun ]); $account = SocialAccount::factory()->create(['network' => 'reddit']); - $post = Post::factory()->create([ - 'type' => 'reddit', - 'title' => 'Hi', - 'target' => 'test', - 'social_account_id' => $account->id, - ]); + $post = Post::factory()->create(['type' => 'reddit', 'title' => 'Hi', 'target' => 'test']); + $post->socialAccounts()->attach($account); - $result = (new RedditPublisher)->publish($post); + $result = (new RedditPublisher)->publish($post->fresh()); expect($result->success)->toBeFalse(); expect($result->attempted)->toBeTrue(); @@ -95,14 +89,10 @@ test('it fails gracefully when the sidecar output is unparseable', function () { ]); $account = SocialAccount::factory()->create(['network' => 'reddit']); - $post = Post::factory()->create([ - 'type' => 'reddit', - 'title' => 'Hi', - 'target' => 'test', - 'social_account_id' => $account->id, - ]); + $post = Post::factory()->create(['type' => 'reddit', 'title' => 'Hi', 'target' => 'test']); + $post->socialAccounts()->attach($account); - $result = (new RedditPublisher)->publish($post); + $result = (new RedditPublisher)->publish($post->fresh()); expect($result->success)->toBeFalse(); expect($result->attempted)->toBeTrue(); diff --git a/tests/Feature/VideoWorkflow/VideoWorkflowModelsTest.php b/tests/Feature/VideoWorkflow/VideoWorkflowModelsTest.php new file mode 100644 index 0000000..f08e9c3 --- /dev/null +++ b/tests/Feature/VideoWorkflow/VideoWorkflowModelsTest.php @@ -0,0 +1,98 @@ +create(); + WorkflowStep::factory()->for($project)->create(['type' => 'video_generation', 'order' => 1]); + WorkflowStep::factory()->for($project)->create(['type' => 'script_generation', 'order' => 0]); + + expect($project->steps->pluck('type')->all())->toBe(['script_generation', 'video_generation']); +}); + +test('a video project belongs to a workflow template', function () { + $template = WorkflowTemplate::factory()->create(); + $project = VideoProject::factory()->create(['workflow_template_id' => $template->id]); + + expect($project->workflowTemplate->id)->toBe($template->id); + expect($template->videoProjects->first()->id)->toBe($project->id); +}); + +test('deleting a video project cascades to its steps and assets', function () { + $project = VideoProject::factory()->create(); + $step = WorkflowStep::factory()->for($project)->create(); + $asset = Asset::factory()->for($project)->create(['workflow_step_id' => $step->id]); + + $project->forceDelete(); + + expect(WorkflowStep::find($step->id))->toBeNull(); + expect(Asset::find($asset->id))->toBeNull(); +}); + +test('a video project is soft deleted by default', function () { + $project = VideoProject::factory()->create(); + + $project->delete(); + + expect(VideoProject::find($project->id))->toBeNull(); + expect(VideoProject::withTrashed()->find($project->id))->not->toBeNull(); +}); + +test('deleting the workflow step an asset belongs to nulls the reference instead of deleting the asset', function () { + $project = VideoProject::factory()->create(); + $step = WorkflowStep::factory()->for($project)->create(); + $asset = Asset::factory()->for($project)->create(['workflow_step_id' => $step->id]); + + $step->delete(); + + expect($asset->fresh()->workflow_step_id)->toBeNull(); +}); + +test('workflow step input/output/requires_approval cast correctly', function () { + $step = WorkflowStep::factory()->create([ + 'requires_approval' => true, + 'input' => ['prompt' => 'write a script'], + 'output' => ['script' => 'once upon a time'], + ]); + + expect($step->fresh()) + ->requires_approval->toBeTrue() + ->input->toBe(['prompt' => 'write a script']) + ->output->toBe(['script' => 'once upon a time']); +}); + +test('asset metadata casts to an array', function () { + $asset = Asset::factory()->create(['metadata' => ['duration_seconds' => 42]]); + + expect($asset->fresh()->metadata)->toBe(['duration_seconds' => 42]); +}); + +test('an ai provider stores its config as an array and defaults to active', function () { + $provider = AiProvider::factory()->create([ + 'provider' => 'elevenlabs', + 'config' => ['voice_id' => 'abc123'], + ]); + + expect($provider->fresh()) + ->provider->toBe('elevenlabs') + ->config->toBe(['voice_id' => 'abc123']) + ->is_active->toBeTrue(); +}); + +test('a workflow template stores its steps as an ordered array', function () { + $template = WorkflowTemplate::factory()->create([ + 'steps' => [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'youtube_publish', 'requires_approval' => false], + ], + ]); + + expect($template->fresh()->steps)->toBe([ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'youtube_publish', 'requires_approval' => false], + ]); +}); diff --git a/tests/Feature/VideoWorkflow/WorkflowEngineTest.php b/tests/Feature/VideoWorkflow/WorkflowEngineTest.php new file mode 100644 index 0000000..fbbc20d --- /dev/null +++ b/tests/Feature/VideoWorkflow/WorkflowEngineTest.php @@ -0,0 +1,257 @@ +engine = new WorkflowEngine; +}); + +test('starting a workflow creates ordered steps and activates the first one', function () { + $project = VideoProject::factory()->create(); + + $first = $this->engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ['type' => 'video_generation'], + ]); + + expect($project->fresh()->status)->toBe('in_progress'); + expect($project->steps)->toHaveCount(3); + + expect($first) + ->type->toBe('script_generation') + ->order->toBe(0) + ->status->toBe('in_progress') + ->requires_approval->toBeTrue(); + expect($first->started_at)->not->toBeNull(); + + expect($project->steps()->where('order', 1)->first()) + ->type->toBe('tts_voiceover') + ->status->toBe('pending'); +}); + +test('it refuses to start a project that already has steps', function () { + $project = VideoProject::factory()->create(); + $this->engine->start($project, [['type' => 'script_generation']]); + + $this->engine->start($project, [['type' => 'tts_voiceover']]); +})->throws(WorkflowTransitionException::class); + +test('it refuses to start a workflow with no steps', function () { + $project = VideoProject::factory()->create(); + + $this->engine->start($project, []); +})->throws(WorkflowTransitionException::class); + +test('startFromTemplate uses the template steps and links the project to it', function () { + $template = WorkflowTemplate::factory()->create([ + 'steps' => [ + ['type' => 'script_generation'], + ['type' => 'youtube_publish'], + ], + ]); + $project = VideoProject::factory()->create(); + + $this->engine->startFromTemplate($project, $template); + + expect($project->fresh()) + ->workflow_template_id->toBe($template->id) + ->status->toBe('in_progress'); + expect($project->steps)->toHaveCount(2); +}); + +test('currentStep returns the earliest non-terminal step', function () { + $project = VideoProject::factory()->create(); + $this->engine->start($project, [ + ['type' => 'script_generation'], + ['type' => 'tts_voiceover'], + ]); + + expect($this->engine->currentStep($project)->type)->toBe('script_generation'); +}); + +test('currentStep is null once every step is completed', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->engine->completeStep($step); + + expect($this->engine->currentStep($project->fresh()))->toBeNull(); +}); + +test('completing a step without approval advances straight to the next step', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation'], + ['type' => 'tts_voiceover'], + ]); + + $completed = $this->engine->completeStep($step, ['script' => 'Hello world']); + + expect($completed) + ->status->toBe('completed') + ->output->toBe(['script' => 'Hello world']); + expect($completed->completed_at)->not->toBeNull(); + + $next = $project->steps()->where('order', 1)->first(); + expect($next->status)->toBe('in_progress'); +}); + +test('completing a step that requires approval stops at awaiting_approval', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ]); + + $completed = $this->engine->completeStep($step, ['script' => 'Draft']); + + expect($completed->status)->toBe('awaiting_approval'); + + $next = $project->steps()->where('order', 1)->first(); + expect($next->status)->toBe('pending'); +}); + +test('completing a step that is not in_progress throws', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + $this->engine->completeStep($step); + + $this->engine->completeStep($step->fresh()); +})->throws(WorkflowTransitionException::class); + +test('approving a step advances the pipeline', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ]); + $this->engine->completeStep($step, ['script' => 'Draft']); + + $approved = $this->engine->approveStep($step->fresh()); + + expect($approved->status)->toBe('completed'); + expect($project->steps()->where('order', 1)->first()->status)->toBe('in_progress'); +}); + +test('rejecting a step blocks the pipeline without advancing', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ]); + $this->engine->completeStep($step, ['script' => 'Bad draft']); + + $rejected = $this->engine->rejectStep($step->fresh(), 'Tone is wrong'); + + expect($rejected) + ->status->toBe('rejected') + ->error->toBe('Tone is wrong'); + expect($project->steps()->where('order', 1)->first()->status)->toBe('pending'); +}); + +test('approving a step that is not awaiting_approval throws', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->engine->approveStep($step); +})->throws(WorkflowTransitionException::class); + +test('a failed step blocks the pipeline and records the error', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation'], + ['type' => 'tts_voiceover'], + ]); + + $failed = $this->engine->failStep($step, 'OpenAI API timed out'); + + expect($failed) + ->status->toBe('failed') + ->error->toBe('OpenAI API timed out'); + expect($project->steps()->where('order', 1)->first()->status)->toBe('pending'); +}); + +test('a failed step can be retried', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + $this->engine->failStep($step, 'timed out'); + + $retried = $this->engine->retryStep($step->fresh()); + + expect($retried) + ->status->toBe('in_progress') + ->error->toBeNull(); +}); + +test('a rejected step can be retried', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation', 'requires_approval' => true]]); + $this->engine->completeStep($step, ['script' => 'v1']); + $this->engine->rejectStep($step->fresh(), 'no good'); + + $retried = $this->engine->retryStep($step->fresh()); + + expect($retried) + ->status->toBe('in_progress') + ->output->toBeNull(); +}); + +test('retrying a step that is not failed or rejected throws', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->engine->retryStep($step); +})->throws(WorkflowTransitionException::class); + +test('skipping a pending or in_progress step advances the pipeline', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [ + ['type' => 'script_generation'], + ['type' => 'tts_voiceover'], + ]); + + $skipped = $this->engine->skipStep($step); + + expect($skipped->status)->toBe('skipped'); + expect($project->steps()->where('order', 1)->first()->status)->toBe('in_progress'); +}); + +test('completing the last step marks the project completed', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->engine->completeStep($step); + + expect($project->fresh()->status)->toBe('completed'); +}); + +test('a full multi-step pipeline runs end to end', function () { + $project = VideoProject::factory()->create(); + $script = $this->engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ['type' => 'video_generation', 'requires_approval' => true], + ]); + + $this->engine->completeStep($script, ['script' => 'Hello']); + $script = $script->fresh(); + expect($script->status)->toBe('awaiting_approval'); + $this->engine->approveStep($script); + + $tts = $this->engine->currentStep($project->fresh()); + expect($tts->type)->toBe('tts_voiceover'); + $this->engine->completeStep($tts, ['audio_path' => '/tmp/a.mp3']); + + $video = $this->engine->currentStep($project->fresh()); + expect($video->type)->toBe('video_generation'); + $this->engine->completeStep($video, ['video_path' => '/tmp/v.mp4']); + $this->engine->approveStep($video->fresh()); + + expect($project->fresh()) + ->status->toBe('completed'); + expect($this->engine->currentStep($project->fresh()))->toBeNull(); +});