'Great post about Stripe Radar', 'image_title' => 'Stripe Radar', 'image_body' => 'Fraud prevention', 'image_keywords' => ['stripe', 'fraud']], ]); PostContentHumanizer::fake([ ['content' => 'Great post about Stripe Radar (humanized)', 'image_title' => 'Stripe Radar', 'image_body' => 'Fraud prevention'], ]); $run = AutomationRun::factory()->create([ 'context' => ['trigger' => ['title' => 'Stripe Radar update']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [], 'prompt_template' => 'Generate a post about: {{ trigger.title }}', 'image_source' => 'none', ]); expect($result->status->value)->toBe('completed'); expect($result->output)->toHaveKey('generated'); expect($result->output['generated'])->toHaveKey('post_id'); expect($result->output['generated']['content'])->toBe('Great post about Stripe Radar (humanized)'); $run->refresh(); expect($run->generated_post_id)->not->toBeNull(); $post = Post::find($run->generated_post_id); expect($post)->not->toBeNull(); expect($post->status)->toBe(PostStatus::Draft); }); it('interpolates the prompt template before passing it to the generator', function () { PostContentGenerator::fake([ ['content' => 'Interpolated content', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']], ]); PostContentHumanizer::fake([ ['content' => 'Interpolated content humanized', 'image_title' => 'Title', 'image_body' => 'Body'], ]); $run = AutomationRun::factory()->create([ 'context' => ['trigger' => ['topic' => 'AI Agents']], ]); app(RunGenerateNode::class)($run, [ 'accounts' => [], 'prompt_template' => 'Write about {{ trigger.topic }}', ]); PostContentGenerator::assertPrompted(fn ($prompt) => $prompt->contains('Write about AI Agents')); }); it('still creates a draft post when humanizer fails', function () { PostContentGenerator::fake([ ['content' => 'Raw content', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']], ]); PostContentHumanizer::fake(fn () => throw new RuntimeException('Humanizer down')); $run = AutomationRun::factory()->create([ 'context' => ['trigger' => ['title' => 'test']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [], 'prompt_template' => 'Write about {{ trigger.title }}', ]); expect($result->status->value)->toBe('completed'); expect($result->output['generated']['content'])->toBe('Raw content'); $run->refresh(); expect($run->generated_post_id)->not->toBeNull(); }); it('derives carousel format when a carousel-capable account is configured with target_slide_count > 1', function () { $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'instagram']); $action = app(RunGenerateNode::class); $accountsConfig = [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => []], ]; ['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]); expect($format)->toBe('carousel'); expect($slideCount)->toBe(5); }); it('derives single format when carousel account has target_slide_count of 1', function () { $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'instagram']); $action = app(RunGenerateNode::class); $accountsConfig = [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => []], ]; ['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 1]); expect($format)->toBe('single'); expect($slideCount)->toBe(1); }); it('derives single format when no carousel-capable account is configured', function () { $action = app(RunGenerateNode::class); $accountsConfig = [ ['social_account_id' => '1', 'content_type' => ContentType::XPost->value, 'meta' => []], ['social_account_id' => '2', 'content_type' => ContentType::InstagramReel->value, 'meta' => []], ]; ['format' => $format, 'slide_count' => $slideCount] = $action->deriveFormat($accountsConfig, ['target_slide_count' => 5]); expect($format)->toBe('single'); expect($slideCount)->toBe(1); }); it('attaches a generated image to a single-format post', function () { PostContentGenerator::fake([ ['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']], ]); PostContentHumanizer::fake([ ['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body'], ]); $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']); $mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []]; $pipeline = Mockery::mock(PostImagePipeline::class); $pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]); app()->instance(PostImagePipeline::class, $pipeline); $automation = Automation::factory()->for($workspace)->create(); $run = AutomationRun::factory()->for($automation)->create([ 'context' => ['trigger' => ['title' => 'test']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []], ], 'prompt_template' => 'Write about {{ trigger.title }}', 'include_image' => true, ]); expect($result->status->value)->toBe('completed'); $post = Post::find($result->output['generated']['post_id']); expect($post)->not->toBeNull(); expect($post->media)->toHaveCount(1); }); it('attaches one image per slide for carousel', function () { PostContentGenerator::fake([ ['caption' => 'Carousel caption', 'slides' => [ ['title' => 'S1', 'body' => 'B1', 'image_keywords' => ['a']], ['title' => 'S2', 'body' => 'B2', 'image_keywords' => ['b']], ['title' => 'S3', 'body' => 'B3', 'image_keywords' => ['c']], ]], ]); PostContentHumanizer::fake([ ['caption' => 'Carousel caption', 'slides' => [ ['title' => 'S1', 'body' => 'B1'], ['title' => 'S2', 'body' => 'B2'], ['title' => 'S3', 'body' => 'B3'], ]], ]); $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'instagram']); $item = fn (int $id) => ['id' => $id, 'path' => "ai-images/{$id}.webp", 'url' => "http://{$id}", 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []]; $pipeline = Mockery::mock(PostImagePipeline::class); $pipeline->shouldReceive('forCarousel')->once()->andReturn([$item(1), $item(2), $item(3)]); app()->instance(PostImagePipeline::class, $pipeline); $automation = Automation::factory()->for($workspace)->create(); $run = AutomationRun::factory()->for($automation)->create([ 'context' => ['trigger' => ['title' => 'test']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => []], ], 'prompt_template' => 'Write about {{ trigger.title }}', 'target_slide_count' => 3, ]); expect($result->status->value)->toBe('completed'); $post = Post::find($result->output['generated']['post_id']); expect($post)->not->toBeNull(); expect($post->media)->toHaveCount(3); }); it('skips images when include_image is false', function () { PostContentGenerator::fake([ ['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']], ]); PostContentHumanizer::fake([ ['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body'], ]); $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']); $pipeline = Mockery::mock(PostImagePipeline::class); $pipeline->shouldNotReceive('forSingle'); $pipeline->shouldNotReceive('forCarousel'); app()->instance(PostImagePipeline::class, $pipeline); $automation = Automation::factory()->for($workspace)->create(); $run = AutomationRun::factory()->for($automation)->create([ 'context' => ['trigger' => ['title' => 'test']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []], ], 'prompt_template' => 'Write about {{ trigger.title }}', 'include_image' => false, ]); expect($result->status->value)->toBe('completed'); $post = Post::find($result->output['generated']['post_id']); expect($post)->not->toBeNull(); expect($post->media)->toHaveCount(0); }); it('does not generate images or persist on a dry run', function () { PostContentGenerator::fake([ ['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']], ]); PostContentHumanizer::fake([ ['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body'], ]); $workspace = Workspace::factory()->create(); $account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']); $pipeline = Mockery::mock(PostImagePipeline::class); $pipeline->shouldNotReceive('forSingle'); $pipeline->shouldNotReceive('forCarousel'); app()->instance(PostImagePipeline::class, $pipeline); $automation = Automation::factory()->for($workspace)->create(); $run = AutomationRun::factory()->for($automation)->create([ 'is_dry_run' => true, 'context' => ['trigger' => ['title' => 'test']], ]); $result = app(RunGenerateNode::class)($run, [ 'accounts' => [ ['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []], ], 'prompt_template' => 'Write about {{ trigger.title }}', 'include_image' => true, ]); expect($result->status->value)->toBe('completed'); expect($result->output['generated']['dry_run'])->toBeTrue(); expect($result->output['generated']['content'])->toBe('Dry run post'); expect($result->output['generated']['image_count'])->toBe(1); expect($result->output['generated']['post_id'])->toBeNull(); expect(Post::count())->toBe(0); expect(Media::count())->toBe(0); $run->refresh(); expect($run->generated_post_id)->toBeNull(); });