diff --git a/app/Models/AiProvider.php b/app/Models/AiProvider.php index c73e861..f7f42bf 100644 --- a/app/Models/AiProvider.php +++ b/app/Models/AiProvider.php @@ -9,7 +9,10 @@ class AiProvider extends Model { use HasFactory; - public const PROVIDERS = ['openai', 'stability_ai', 'elevenlabs', 'other']; + // 'youtube' isn't an AI provider, but reuses this same credentials + // shape: api_key holds an OAuth refresh_token, config holds + // client_id/client_secret. See YoutubePublishHandler. + public const PROVIDERS = ['openai', 'stability_ai', 'elevenlabs', 'youtube', 'other']; protected $fillable = [ 'name', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 722c92a..a44a36b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,12 +11,15 @@ use App\Support\Publishing\PublisherManager; use App\Support\Publishing\RedditPublisher; use App\Support\Publishing\TwitterPublisher; use App\Support\Publishing\WordPressPublisher; +use App\Support\VideoWorkflow\StepHandlers\MetadataGenerationHandler; use App\Support\VideoWorkflow\StepHandlers\ScriptGenerationHandler; use App\Support\VideoWorkflow\StepHandlers\StepHandlerManager; use App\Support\VideoWorkflow\StepHandlers\StoryboardHandler; +use App\Support\VideoWorkflow\StepHandlers\ThumbnailGenerationHandler; use App\Support\VideoWorkflow\StepHandlers\TtsVoiceoverHandler; use App\Support\VideoWorkflow\StepHandlers\VideoEditingHandler; use App\Support\VideoWorkflow\StepHandlers\VideoGenerationHandler; +use App\Support\VideoWorkflow\StepHandlers\YoutubePublishHandler; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -45,6 +48,9 @@ class AppServiceProvider extends ServiceProvider $app->make(StoryboardHandler::class), $app->make(VideoGenerationHandler::class), $app->make(VideoEditingHandler::class), + $app->make(ThumbnailGenerationHandler::class), + $app->make(MetadataGenerationHandler::class), + $app->make(YoutubePublishHandler::class), ])); } diff --git a/app/Support/VideoWorkflow/StepHandlers/MetadataGenerationHandler.php b/app/Support/VideoWorkflow/StepHandlers/MetadataGenerationHandler.php new file mode 100644 index 0000000..e2b0583 --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/MetadataGenerationHandler.php @@ -0,0 +1,105 @@ +where('provider', 'openai')->where('is_active', true)->first(); + + if (! $provider) { + $engine->failStep($step, 'No active OpenAI provider configured.'); + + return; + } + + $script = $this->resolveScript($step); + + if (! $script) { + $engine->failStep($step, 'No script available to generate metadata from — complete a script_generation step first.'); + + return; + } + + try { + $result = $this->aiProviders->generate($provider, $this->buildPrompt($script), ['json_mode' => true]); + } catch (AiGenerationException $e) { + $engine->failStep($step, $e->getMessage()); + + return; + } + + $decoded = json_decode($result->content, true); + + if (! is_array($decoded) || empty($decoded['title']) || empty($decoded['description'])) { + $engine->failStep($step, 'AI provider returned unusable metadata: missing title/description.'); + + return; + } + + $decoded += ['tags' => [], 'hashtags' => []]; + + $asset = $this->assets->store( + $step->videoProject, + 'other', + json_encode($decoded, JSON_PRETTY_PRINT), + 'json', + step: $step, + mimeType: 'application/json', + metadata: ['kind' => 'metadata'], + ); + + $engine->completeStep($step, [ + 'asset_id' => $asset->id, + 'title' => $decoded['title'], + ]); + } + + private function resolveScript(WorkflowStep $step): ?string + { + $input = $step->input ?? []; + + if (! empty($input['script'])) { + return $input['script']; + } + + $scriptAsset = $step->videoProject->assets() + ->where('type', 'script') + ->orderByDesc('version') + ->first(); + + return $scriptAsset ? $this->assets->contents($scriptAsset) : null; + } + + private function buildPrompt(string $script): string + { + return "Based on the following YouTube video script, generate SEO-optimized metadata.\n\n" + ."Script:\n{$script}\n\n" + .'Respond with JSON only, in exactly this shape: ' + .'{"title": "...", "description": "...", "tags": ["...", "..."], "hashtags": ["#...", "#..."]}. ' + .'Title must be under 100 characters. Description should be 2-4 paragraphs with a natural, ' + .'keyword-rich summary. Provide 10-15 tags and 3-5 hashtags.'; + } +} diff --git a/app/Support/VideoWorkflow/StepHandlers/ThumbnailGenerationHandler.php b/app/Support/VideoWorkflow/StepHandlers/ThumbnailGenerationHandler.php new file mode 100644 index 0000000..2aed21b --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/ThumbnailGenerationHandler.php @@ -0,0 +1,86 @@ +where('provider', 'stability_ai')->where('is_active', true)->first(); + + if (! $provider) { + $engine->failStep($step, 'No active StabilityAI provider configured.'); + + return; + } + + $input = $step->input ?? []; + $basePrompt = $input['prompt'] ?? $this->defaultPrompt($step->videoProject); + $styles = $input['styles'] ?? self::DEFAULT_STYLES; + + $assetIds = []; + + foreach (array_values($styles) as $i => $style) { + $prompt = "{$basePrompt}. Eye-catching YouTube thumbnail, bold, high contrast, {$style}."; + + try { + $result = $this->aiProviders->generate($provider, $prompt); + } catch (AiGenerationException $e) { + $engine->failStep($step, 'variant '.($i + 1).": {$e->getMessage()}"); + + return; + } + + $extension = str($result->contentType)->after('image/')->toString() ?: 'png'; + + $asset = $this->assets->store( + $step->videoProject, + 'image', + $result->content, + $extension, + step: $step, + mimeType: $result->contentType, + metadata: ['kind' => 'thumbnail', 'variant' => $i + 1, 'style' => $style], + ); + + $assetIds[] = $asset->id; + } + + $engine->completeStep($step, [ + 'asset_ids' => $assetIds, + 'variant_count' => count($assetIds), + ]); + } + + private function defaultPrompt(VideoProject $project): string + { + return trim(($project->title ?? '').'. '.($project->description ?? '')); + } +} diff --git a/app/Support/VideoWorkflow/StepHandlers/YoutubePublishHandler.php b/app/Support/VideoWorkflow/StepHandlers/YoutubePublishHandler.php new file mode 100644 index 0000000..32ab5ac --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/YoutubePublishHandler.php @@ -0,0 +1,186 @@ +where('provider', 'youtube')->where('is_active', true)->first(); + + if (! $credential) { + $engine->failStep($step, 'No active YouTube credential configured.'); + + return; + } + + $videoAsset = $step->videoProject->assets()->where('type', 'video')->orderByDesc('version')->first(); + + if (! $videoAsset) { + $engine->failStep($step, 'No finished video available — complete a video_editing step first.'); + + return; + } + + try { + $accessToken = $this->refreshAccessToken($credential); + } catch (RuntimeException|ConnectionException $e) { + $engine->failStep($step, "could not refresh YouTube access token: {$e->getMessage()}"); + + return; + } + + $metadata = $this->resolveMetadata($step); + $input = $step->input ?? []; + $publishAt = $input['publish_at'] ?? null; + + $snippet = [ + 'title' => $metadata['title'] ?? $step->videoProject->title, + 'description' => $metadata['description'] ?? ($step->videoProject->description ?? ''), + 'tags' => $metadata['tags'] ?? [], + ]; + $status = array_filter([ + 'privacyStatus' => $publishAt ? 'private' : ($input['privacy_status'] ?? 'private'), + 'publishAt' => $publishAt, + ]); + + $videoBytes = $this->assets->contents($videoAsset); + + try { + $initResponse = Http::withToken($accessToken) + ->withHeaders([ + 'X-Upload-Content-Type' => 'video/mp4', + 'X-Upload-Content-Length' => (string) strlen($videoBytes), + ]) + ->post('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', [ + 'snippet' => $snippet, + 'status' => $status, + ]); + } catch (ConnectionException $e) { + $engine->failStep($step, "could not connect to YouTube: {$e->getMessage()}"); + + return; + } + + if ($initResponse->failed()) { + $engine->failStep($step, $initResponse->json('error.message') ?? 'unexpected response from YouTube while starting the upload'); + + return; + } + + $uploadUrl = $initResponse->header('Location'); + + if (! $uploadUrl) { + $engine->failStep($step, 'unexpected response from YouTube: missing upload session location'); + + return; + } + + try { + $uploadResponse = Http::withToken($accessToken) + ->withBody($videoBytes, 'video/mp4') + ->put($uploadUrl); + } catch (ConnectionException $e) { + $engine->failStep($step, "could not upload video to YouTube: {$e->getMessage()}"); + + return; + } + + if ($uploadResponse->failed()) { + $engine->failStep($step, $uploadResponse->json('error.message') ?? 'unexpected response from YouTube while uploading the video'); + + return; + } + + $videoId = $uploadResponse->json('id'); + + if (! $videoId) { + $engine->failStep($step, 'unexpected response from YouTube: missing video id'); + + return; + } + + $engine->completeStep($step, [ + 'youtube_video_id' => $videoId, + 'youtube_url' => "https://www.youtube.com/watch?v={$videoId}", + ]); + } + + /** + * @throws RuntimeException|ConnectionException + */ + private function refreshAccessToken(AiProvider $credential): string + { + $config = $credential->config ?? []; + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => $config['client_id'] ?? null, + 'client_secret' => $config['client_secret'] ?? null, + 'refresh_token' => $credential->api_key, + 'grant_type' => 'refresh_token', + ]); + + if ($response->failed()) { + throw new RuntimeException($response->json('error_description') ?? 'token refresh failed'); + } + + $token = $response->json('access_token'); + + if (! $token) { + throw new RuntimeException('no access_token in refresh response'); + } + + return $token; + } + + /** + * @return array + */ + private function resolveMetadata(WorkflowStep $step): array + { + $input = $step->input ?? []; + + if (! empty($input['title']) || ! empty($input['description'])) { + return $input; + } + + $metaAsset = $step->videoProject->assets() + ->where('type', 'other') + ->whereJsonContains('metadata->kind', 'metadata') + ->orderByDesc('version') + ->first(); + + if (! $metaAsset) { + return []; + } + + return json_decode($this->assets->contents($metaAsset), true) ?? []; + } +} diff --git a/tests/Feature/Jobs/RunWorkflowStepJobTest.php b/tests/Feature/Jobs/RunWorkflowStepJobTest.php index 9fdf6c2..1b40abb 100644 --- a/tests/Feature/Jobs/RunWorkflowStepJobTest.php +++ b/tests/Feature/Jobs/RunWorkflowStepJobTest.php @@ -28,7 +28,9 @@ test('it runs the matching handler and completes the step', function () { test('it fails the step when no handler is registered for its type', function () { $project = VideoProject::factory()->create(); - $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); + // A made-up type — not a real WorkflowStep::TYPES value — so this test + // stays valid regardless of which real types have handlers over time. + $step = $this->engine->start($project, [['type' => 'some_future_step_type']]); (new RunWorkflowStepJob($step->id))->handle(app(StepHandlerManager::class), $this->engine); diff --git a/tests/Feature/Livewire/VideoProjectsShowTest.php b/tests/Feature/Livewire/VideoProjectsShowTest.php index e731094..4e8c7f2 100644 --- a/tests/Feature/Livewire/VideoProjectsShowTest.php +++ b/tests/Feature/Livewire/VideoProjectsShowTest.php @@ -31,9 +31,11 @@ test('starting the default pipeline creates steps and queues the first handled o test('starting from a template uses its steps and does not queue unhandled types', function () { Queue::fake(); + // Made-up types — not real WorkflowStep::TYPES values — so this test + // stays valid regardless of which real types have handlers over time. $template = WorkflowTemplate::factory()->create(['steps' => [ - ['type' => 'thumbnail_generation'], - ['type' => 'youtube_publish'], + ['type' => 'some_future_step_type'], + ['type' => 'another_future_step_type'], ]]); $project = VideoProject::factory()->create(); @@ -44,7 +46,6 @@ test('starting from a template uses its steps and does not queue unhandled types expect($project->fresh()->workflow_template_id)->toBe($template->id); expect($project->steps()->count())->toBe(2); - // thumbnail_generation has no handler yet, so nothing should be queued Queue::assertNotPushed(RunWorkflowStepJob::class); }); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/MetadataGenerationHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/MetadataGenerationHandlerTest.php new file mode 100644 index 0000000..8f791e5 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/MetadataGenerationHandlerTest.php @@ -0,0 +1,90 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new MetadataGenerationHandler(app(AiProviderManager::class), $this->assets); +}); + +test('it generates metadata from the script, stores it, and completes the step', function () { + $metadata = [ + 'title' => 'The Best Coffee Gadgets of 2026', + 'description' => 'A deep dive into coffee gadgets.', + 'tags' => ['coffee', 'gadgets'], + 'hashtags' => ['#coffee', '#gadgets'], + ]; + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => json_encode($metadata)]]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'A script about coffee gadgets.', 'txt'); + $step = $this->engine->start($project, [['type' => 'metadata_generation']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['title'])->toBe('The Best Coffee Gadgets of 2026'); + + $asset = Asset::find($fresh->output['asset_id']); + expect($asset->type)->toBe('other'); + expect($asset->metadata['kind'])->toBe('metadata'); + $stored = json_decode(Storage::get($asset->disk_path), true); + expect($stored)->toBe($metadata); + + Http::assertSent(function ($request) { + return str_contains($request['messages'][0]['content'], 'A script about coffee gadgets.') + && ($request['response_format']['type'] ?? null) === 'json_object'; + }); +}); + +test('it fails when there is no script to generate metadata from', function () { + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'metadata_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No script available'); +}); + +test('it fails when no active OpenAI provider is configured', function () { + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'a script', 'txt'); + $step = $this->engine->start($project, [['type' => 'metadata_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active OpenAI provider'); +}); + +test('it fails when the AI response is missing title or description', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => '{"tags": []}']]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'a script', 'txt'); + $step = $this->engine->start($project, [['type' => 'metadata_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('unusable metadata'); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/ThumbnailGenerationHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/ThumbnailGenerationHandlerTest.php new file mode 100644 index 0000000..6a4067b --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/ThumbnailGenerationHandlerTest.php @@ -0,0 +1,79 @@ +engine = new WorkflowEngine; + $this->handler = new ThumbnailGenerationHandler(app(AiProviderManager::class), new AssetStorage); +}); + +test('it generates two default-style variants and completes the step', function () { + Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('bytes')])]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(['title' => 'Top 10 Gadgets']); + $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['variant_count'])->toBe(2); + expect($fresh->output['asset_ids'])->toHaveCount(2); + + $assets = Asset::whereIn('id', $fresh->output['asset_ids'])->get(); + $assets->each(fn ($a) => expect($a->type)->toBe('image')); + expect($assets->pluck('metadata.style')->sort()->values()->all()) + ->toBe(['minimalist and clean', 'vibrant and colorful']); + + Http::assertSentCount(2); + Http::assertSent(fn ($request) => str_contains($request->body(), 'Top 10 Gadgets')); +}); + +test('explicit styles in input override the default variants', function () { + Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('bytes')])]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); + $step->update(['input' => ['styles' => ['noir', 'retro', 'futuristic']]]); + + $this->handler->handle($step->fresh(), $this->engine); + + expect($step->fresh()->output['variant_count'])->toBe(3); + Http::assertSentCount(3); +}); + +test('it fails the step when no active StabilityAI provider is configured', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active StabilityAI provider'); +}); + +test('it fails the step and reports which variant failed', function () { + Http::fake(['api.stability.ai/*' => Http::response(['errors' => ['bad prompt']], 400)]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('variant 1'); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/YoutubePublishHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/YoutubePublishHandlerTest.php new file mode 100644 index 0000000..6148edd --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/YoutubePublishHandlerTest.php @@ -0,0 +1,191 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new YoutubePublishHandler($this->assets); + + $this->fakeYoutube = function (array $overrides = []) { + Http::fake(array_merge([ + 'oauth2.googleapis.com/*' => Http::response(['access_token' => 'access-token-123']), + 'https://www.googleapis.com/upload/youtube/v3/videos*' => Http::response('', 200, [ + 'Location' => 'https://upload.example.com/session/abc123', + ]), + 'upload.example.com/*' => Http::response(['id' => 'yt-video-id']), + ], $overrides)); + }; +}); + +test('it uploads the video with metadata and completes the step', function () { + ($this->fakeYoutube)(); + AiProvider::factory()->create([ + 'provider' => 'youtube', + 'api_key' => 'refresh-token-abc', + 'config' => ['client_id' => 'cid', 'client_secret' => 'csecret'], + ]); + + $project = VideoProject::factory()->create(['title' => 'Fallback Title']); + $this->assets->store($project, 'video', 'fake-mp4-bytes', 'mp4', mimeType: 'video/mp4'); + $this->assets->store( + $project, 'other', + json_encode(['title' => 'Great Video', 'description' => 'A great description', 'tags' => ['a', 'b']]), + 'json', mimeType: 'application/json', metadata: ['kind' => 'metadata'], + ); + + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['youtube_video_id'])->toBe('yt-video-id'); + expect($fresh->output['youtube_url'])->toBe('https://www.youtube.com/watch?v=yt-video-id'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://oauth2.googleapis.com/token' + && $request['client_id'] === 'cid' + && $request['client_secret'] === 'csecret' + && $request['refresh_token'] === 'refresh-token-abc' + && $request['grant_type'] === 'refresh_token'; + }); + + Http::assertSent(function ($request) { + return str_starts_with($request->url(), 'https://www.googleapis.com/upload/youtube/v3/videos') + && $request->hasHeader('Authorization', 'Bearer access-token-123') + && $request->hasHeader('X-Upload-Content-Type', 'video/mp4') + && $request['snippet']['title'] === 'Great Video' + && $request['snippet']['description'] === 'A great description' + && $request['status']['privacyStatus'] === 'private'; + }); + + Http::assertSent(function ($request) { + return $request->url() === 'https://upload.example.com/session/abc123' + && $request->body() === 'fake-mp4-bytes'; + }); +}); + +test('it falls back to the project title/description when there is no metadata asset', function () { + ($this->fakeYoutube)(); + AiProvider::factory()->create(['provider' => 'youtube', 'config' => ['client_id' => 'cid', 'client_secret' => 'cs']]); + + $project = VideoProject::factory()->create(['title' => 'Fallback Title', 'description' => 'Fallback description']); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()->status)->toBe('completed'); + + Http::assertSent(fn ($request) => ($request['snippet']['title'] ?? null) === 'Fallback Title' + && ($request['snippet']['description'] ?? null) === 'Fallback description'); +}); + +test('publish_at input schedules the video and forces privacyStatus to private', function () { + ($this->fakeYoutube)(); + AiProvider::factory()->create(['provider' => 'youtube', 'config' => ['client_id' => 'cid', 'client_secret' => 'cs']]); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + $step->update(['input' => ['publish_at' => '2026-12-01T09:00:00Z', 'privacy_status' => 'public']]); + + $this->handler->handle($step->fresh(), $this->engine); + + expect($step->fresh()->status)->toBe('completed'); + + Http::assertSent(fn ($request) => ($request['status']['publishAt'] ?? null) === '2026-12-01T09:00:00Z' + && ($request['status']['privacyStatus'] ?? null) === 'private'); +}); + +test('it fails when there is no finished video', function () { + AiProvider::factory()->create(['provider' => 'youtube']); + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No finished video available'); +}); + +test('it fails when no active YouTube credential is configured', function () { + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active YouTube credential'); +}); + +test('it fails when the token refresh fails', function () { + ($this->fakeYoutube)(['oauth2.googleapis.com/*' => Http::response(['error_description' => 'invalid_grant'], 400)]); + AiProvider::factory()->create(['provider' => 'youtube']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('invalid_grant'); +}); + +test('it fails when starting the upload session fails', function () { + ($this->fakeYoutube)(['https://www.googleapis.com/upload/youtube/v3/videos*' => Http::response(['error' => ['message' => 'quota exceeded']], 403)]); + AiProvider::factory()->create(['provider' => 'youtube']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toBe('quota exceeded'); +}); + +test('it fails when the upload session response has no Location header', function () { + ($this->fakeYoutube)(['https://www.googleapis.com/upload/youtube/v3/videos*' => Http::response('', 200)]); + AiProvider::factory()->create(['provider' => 'youtube']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('missing upload session location'); +}); + +test('it fails when the final upload fails', function () { + ($this->fakeYoutube)(['upload.example.com/*' => Http::response(['error' => ['message' => 'upload interrupted']], 500)]); + AiProvider::factory()->create(['provider' => 'youtube']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'video', 'bytes', 'mp4', mimeType: 'video/mp4'); + $step = $this->engine->start($project, [['type' => 'youtube_publish']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toBe('upload interrupted'); +});