create(); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->andThrow(new RuntimeException('OpenAI unavailable')); }); $response = $this ->actingAs($user) ->from(route('social.index')) ->post(route('social.generate'), [ 'topic' => 'Promo hosting ramadhan', 'tone' => 'friendly-professional', 'platform_hint' => 'facebook, instagram', ]); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_error', 'OpenAI unavailable'); } public function test_instagram_posts_require_an_image_url(): void { $user = User::factory()->create(); $response = $this ->actingAs($user) ->from(route('social.index')) ->post(route('social.store'), [ 'platforms' => ['instagram'], 'title' => 'Promo baru', 'caption' => 'Caption promo', 'hashtags' => 'promo,bisnis', 'scheduled_at' => now()->addHour()->toDateTimeString(), 'status' => 'scheduled', ]); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_error', 'Instagram membutuhkan image_url publik sebelum post bisa dibuat.'); $this->assertDatabaseCount('social_posts', 0); } public function test_tiktok_posts_require_a_video_url(): void { $user = User::factory()->create(); $response = $this ->actingAs($user) ->from(route('social.index')) ->post(route('social.store'), [ 'platforms' => ['tiktok'], 'title' => 'Promo video', 'caption' => 'Caption tiktok', 'hashtags' => 'promo,video', 'scheduled_at' => now()->addHour()->toDateTimeString(), 'status' => 'scheduled', ]); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_error', 'TikTok membutuhkan video_url publik sebelum post bisa dibuat.'); $this->assertDatabaseCount('social_posts', 0); } public function test_social_index_displays_story_media_lab_controls(): void { $user = User::factory()->create(); $this->actingAs($user) ->get(route('social.index')) ->assertOk() ->assertSeeText('Auto generate content lalu schedule publish') ->assertSeeText('Recurring campaign manager') ->assertSeeText('Campaign Filter') ->assertSeeText('Repeat schedule') ->assertSeeText('Generate Image 9:16') ->assertSeeText('Generate Video 9:16') ->assertSeeText('Insert to Post'); } public function test_credential_settings_page_is_displayed(): void { $user = User::factory()->create(); $this->actingAs($user) ->get(route('social.credentials.index')) ->assertOk() ->assertSeeText('Setting Credential') ->assertSeeText('Setup needed') ->assertSeeText('/home/central_webhook_callback'); } public function test_media_image_generation_returns_json_payload(): void { $user = User::factory()->create(); $this->mock(SocialMediaStudioService::class, function (MockInterface $mock): void { $mock->shouldReceive('generateImage') ->once() ->with('Visual social premium 9:16') ->andReturn([ 'url' => '/storage/images/story-1.jpg', 'thumbnail_url' => '/storage/images/story-thumb-1.jpg', 'variants' => [ [ 'url' => '/storage/images/story-1.jpg', 'thumbnail_url' => '/storage/images/story-thumb-1.jpg', 'width' => 1080, 'height' => 1920, ], [ 'url' => '/storage/images/story-2.jpg', 'thumbnail_url' => '/storage/images/story-thumb-2.jpg', 'width' => 1080, 'height' => 1920, ], ], 'provider' => 'poster', 'aspect' => '9:16', 'width' => 1080, 'height' => 1920, 'prompt' => 'Visual social premium 9:16', 'notice' => '4 image story-size berhasil dibuat.', ]); }); $this->actingAs($user) ->postJson(route('social.media.image'), [ 'prompt' => 'Visual social premium 9:16', ]) ->assertOk() ->assertJsonPath('media.url', '/storage/images/story-1.jpg') ->assertJsonCount(2, 'media.variants') ->assertJsonPath('media.variants.0.width', 1080) ->assertJsonPath('media.variants.0.height', 1920) ->assertJsonPath('media.provider', 'poster') ->assertJsonPath('media.aspect', '9:16'); } public function test_media_video_generation_returns_json_payload(): void { $user = User::factory()->create(); $this->mock(SocialMediaStudioService::class, function (MockInterface $mock): void { $mock->shouldReceive('generateVideo') ->once() ->with('Video launch vertical 9:16') ->andReturn([ 'url' => '/storage/videos/story-launch.mp4', 'provider' => 'motion-video', 'aspect' => '9:16', 'prompt' => 'Video launch vertical 9:16', 'duration_seconds' => 6, 'source_image_url' => '/storage/images/story-launch.png', 'source_image_provider' => 'poster', 'notice' => 'Video dibuat dari visual portrait 9:16 yang paling baru.', ]); }); $this->actingAs($user) ->postJson(route('social.media.video'), [ 'prompt' => 'Video launch vertical 9:16', ]) ->assertOk() ->assertJsonPath('media.url', '/storage/videos/story-launch.mp4') ->assertJsonPath('media.provider', 'motion-video') ->assertJsonPath('media.aspect', '9:16'); } public function test_compose_video_music_returns_mixed_video_and_saves_asset(): void { $user = User::factory()->create(); $this->mock(FreepikMusicService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('upbeat electronic ads', 15) ->andReturn([ 'url' => 'https://cdn.example.com/music/track.mp3', 'provider' => 'freepik-music', 'duration_seconds' => 15, ]); }); $this->mock(VideoMusicComposerService::class, function (MockInterface $mock): void { $mock->shouldReceive('compose') ->once() ->with('/storage/videos/source.mp4', 'https://cdn.example.com/music/track.mp3', 0.24) ->andReturn([ 'url' => '/storage/videos/mixed.mp4', 'relative_path' => 'videos/mixed.mp4', 'music_volume' => 0.24, ]); }); $this->actingAs($user) ->postJson(route('social.media.music'), [ 'video_url' => '/storage/videos/source.mp4', 'music_prompt' => 'upbeat electronic ads', 'music_duration' => 15, 'music_volume' => 0.24, ]) ->assertOk() ->assertJsonPath('mixed_video_url', '/storage/videos/mixed.mp4') ->assertJsonPath('saved_asset.provider', 'freepik-music-mix') ->assertJsonPath('music.provider', 'freepik-music'); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'video', 'provider' => 'freepik-music-mix', 'url' => '/storage/videos/mixed.mp4', ]); } public function test_saving_credentials_preserves_existing_token_and_meta_when_left_blank(): void { $user = User::factory()->create(); $credential = SocialPlatformCredential::create([ 'user_id' => $user->id, 'platform' => 'facebook', 'account_label' => 'Main page', 'account_id' => 'page-1', 'access_token' => 'token-lama', 'meta' => [ 'client_id' => 'client-lama', 'client_secret' => 'secret-lama', ], ]); $response = $this ->actingAs($user) ->from(route('social.credentials.index')) ->post(route('social.credentials.store'), [ 'platform' => 'facebook', 'account_label' => 'Main page updated', 'account_id' => 'page-2', 'access_token' => '', 'meta_client_id' => '', 'meta_client_secret' => '', ]); $response ->assertRedirect(route('social.credentials.index').'#platform-facebook') ->assertSessionHasNoErrors(); $credential->refresh(); $this->assertSame('Main page updated', $credential->account_label); $this->assertSame('page-2', $credential->account_id); $this->assertSame('token-lama', $credential->access_token); $this->assertSame([ 'client_id' => 'client-lama', 'client_secret' => 'secret-lama', ], $credential->meta); } public function test_media_upload_saves_asset_to_gallery(): void { Storage::fake('public'); $user = User::factory()->create(); $this->actingAs($user) ->postJson(route('social.media.upload'), [ 'asset' => UploadedFile::fake()->image('model.jpg', 1080, 1920), 'label' => 'Model referensi', ]) ->assertOk() ->assertJsonPath('saved_asset.type', 'image') ->assertJsonPath('saved_asset.provider', 'upload'); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => 'Model referensi', ]); } public function test_remix_media_returns_generated_payload_and_saves_asset(): void { $user = User::factory()->create(); $source = SocialMediaAsset::query()->create([ 'user_id' => $user->id, 'type' => 'image', 'provider' => 'upload', 'prompt' => 'source', 'url' => '/storage/social_assets/images/source.jpg', 'thumbnail_url' => '/storage/social_assets/images/source.jpg', ]); $this->mock(SocialMediaStudioService::class, function (MockInterface $mock): void { $mock->shouldReceive('remixFromAsset') ->once() ->andReturn([ 'url' => '/storage/images/remix-1.jpg', 'thumbnail_url' => '/storage/images/remix-1-thumb.jpg', 'variants' => [[ 'url' => '/storage/images/remix-1.jpg', 'thumbnail_url' => '/storage/images/remix-1-thumb.jpg', 'width' => 1080, 'height' => 1920, ]], 'provider' => 'poster', 'aspect' => '9:16', 'prompt' => 'ganti background studio', ]); }); $this->actingAs($user) ->postJson(route('social.media.remix'), [ 'source_asset_id' => $source->id, 'prompt' => 'ganti background studio', 'mode' => 'mockup', 'output_type' => 'image', ]) ->assertOk() ->assertJsonPath('media.provider', 'poster') ->assertJsonPath('saved_assets.0.type', 'image'); } public function test_publish_now_rejects_posts_that_are_already_posted(): void { $user = User::factory()->create(); $post = SocialPost::create([ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'posted', 'requires_approval' => false, 'caption' => 'Sudah terbit', 'scheduled_at' => now()->subHour(), 'posted_at' => now()->subMinutes(10), ]); $response = $this ->actingAs($user) ->post(route('social.publish-now', $post)); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_error', 'Post ini sudah pernah dipublish.'); } public function test_publish_now_auto_applies_freepik_bgm_when_enabled(): void { $user = User::factory()->create(); SocialPlatformCredential::create([ 'user_id' => $user->id, 'platform' => 'x', 'access_token' => 'token-x', ]); $post = SocialPost::create([ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'scheduled', 'requires_approval' => false, 'caption' => 'Video campaign', 'video_url' => '/storage/videos/source.mp4', 'scheduled_at' => now()->subHour(), 'meta' => [ 'bgm' => [ 'enabled' => true, 'provider' => 'freepik', 'prompt' => 'upbeat electronic ads', 'duration' => 15, 'volume' => 0.24, 'applied' => false, ], ], ]); $this->mock(FreepikMusicService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('upbeat electronic ads', 15) ->andReturn([ 'url' => 'https://cdn.example.com/music/track.mp3', 'provider' => 'freepik-music', 'duration_seconds' => 15, ]); }); $this->mock(VideoMusicComposerService::class, function (MockInterface $mock): void { $mock->shouldReceive('compose') ->once() ->with('/storage/videos/source.mp4', 'https://cdn.example.com/music/track.mp3', 0.24) ->andReturn([ 'url' => '/storage/videos/source-mixed.mp4', 'relative_path' => 'videos/source-mixed.mp4', 'music_volume' => 0.24, ]); }); $this->mock(SocialPublisherService::class, function (MockInterface $mock): void { $mock->shouldReceive('publish') ->once() ->andReturn([ 'external_post_id' => 'tweet-456', 'payload' => ['ok' => true], ]); }); $this->actingAs($user) ->post(route('social.publish-now', $post)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Post berhasil dipublish ke x.'); $post->refresh(); $this->assertSame('/storage/videos/source-mixed.mp4', $post->video_url); $this->assertTrue((bool) data_get($post->meta, 'bgm.applied')); } public function test_publish_now_clears_retry_metadata_after_success(): void { $user = User::factory()->create(); SocialPlatformCredential::create([ 'user_id' => $user->id, 'platform' => 'x', 'access_token' => 'token-x', ]); $post = SocialPost::create([ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'failed', 'requires_approval' => false, 'caption' => 'Coba kirim ulang', 'scheduled_at' => now()->subHour(), 'attempt_count' => 2, 'next_retry_at' => now()->addMinutes(15), ]); $this->mock(SocialPublisherService::class, function (MockInterface $mock): void { $mock->shouldReceive('publish') ->once() ->andReturn([ 'external_post_id' => 'tweet-123', 'payload' => ['ok' => true], ]); }); $response = $this ->actingAs($user) ->post(route('social.publish-now', $post)); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Post berhasil dipublish ke x.'); $post->refresh(); $this->assertSame('posted', $post->status); $this->assertNull($post->next_retry_at); $this->assertNotNull($post->approved_at); $this->assertSame('tweet-123', $post->external_post_id); } public function test_generate_and_schedule_creates_posts_from_ai_result(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('Promo medilis cloud', 'facebook, x', 'friendly-professional') ->andReturn([ 'title' => 'Promo Medilis Cloud', 'caption' => 'Kelola operasional klinik lebih cepat dengan Medilis Cloud.', 'hashtags' => ['#medilis', '#klinikdigital'], 'cta' => 'Coba demo hari ini.', ]); }); $response = $this ->actingAs($user) ->from(route('social.index')) ->post(route('social.generate-and-schedule'), [ 'topic' => 'Promo medilis cloud', 'tone' => 'friendly-professional', 'platform_hint' => 'facebook, x', 'platforms' => ['facebook', 'x'], 'scheduled_at' => now()->addHour()->toDateTimeString(), 'status' => 'scheduled', 'bgm_auto' => '1', 'bgm_prompt' => 'upbeat electronic ads', 'bgm_duration' => 15, 'bgm_volume' => 0.24, ]); $response ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Konten AI berhasil dibuat dan langsung masuk ke queue automation.'); $this->assertDatabaseCount('social_posts', 2); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'facebook', 'status' => 'scheduled', 'title' => 'Promo Medilis Cloud', ]); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'scheduled', 'title' => 'Promo Medilis Cloud', ]); $post = SocialPost::query()->where('user_id', $user->id)->where('platform', 'facebook')->firstOrFail(); $this->assertSame('Kelola operasional klinik lebih cepat dengan Medilis Cloud.'."\n\n".'Coba demo hari ini.', $post->caption); $this->assertSame(['medilis', 'klinikdigital'], $post->hashtags); $this->assertTrue((bool) data_get($post->meta, 'bgm.enabled')); } public function test_generate_and_schedule_auto_generates_media_when_requested(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('Promo lengkap medilis', 'instagram, tiktok', 'friendly-professional') ->andReturn([ 'title' => 'Promo Lengkap Medilis', 'caption' => 'Satu dashboard untuk klinik, lab, dan apotek.', 'hashtags' => ['#medilis', '#healthtech'], 'cta' => 'Jadwalkan demo sekarang.', ]); }); $this->mock(SocialMediaStudioService::class, function (MockInterface $mock): void { $mock->shouldReceive('generateImage') ->once() ->with('Hero visual Medilis untuk Instagram') ->andReturn([ 'url' => '/storage/images/fastlane-hero.jpg', 'thumbnail_url' => '/storage/images/fastlane-hero-thumb.jpg', 'variants' => [[ 'url' => '/storage/images/fastlane-hero.jpg', 'thumbnail_url' => '/storage/images/fastlane-hero-thumb.jpg', 'width' => 1080, 'height' => 1920, ]], 'provider' => 'poster', 'prompt' => 'Hero visual Medilis untuk Instagram', ]); $mock->shouldReceive('generateVideo') ->once() ->with('Video promo Medilis untuk TikTok') ->andReturn([ 'url' => '/storage/videos/fastlane-video.mp4', 'provider' => 'motion-video', 'aspect' => '9:16', 'prompt' => 'Video promo Medilis untuk TikTok', 'duration_seconds' => 6, 'source_image_url' => '/storage/images/fastlane-hero.jpg', 'source_image_provider' => 'poster', ]); }); $this->actingAs($user) ->from(route('social.index')) ->post(route('social.generate-and-schedule'), [ 'topic' => 'Promo lengkap medilis', 'tone' => 'friendly-professional', 'platform_hint' => 'instagram, tiktok', 'platforms' => ['instagram', 'tiktok'], 'auto_generate_image' => '1', 'auto_generate_video' => '1', 'image_prompt' => 'Hero visual Medilis untuk Instagram', 'video_prompt' => 'Video promo Medilis untuk TikTok', 'scheduled_at' => now()->addHour()->toDateTimeString(), 'status' => 'scheduled', 'bgm_auto' => '1', 'bgm_prompt' => 'upbeat electronic ads', 'bgm_duration' => 15, 'bgm_volume' => 0.24, ]) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Konten AI berhasil dibuat dan langsung masuk ke queue automation.'); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'instagram', 'image_url' => '/storage/images/fastlane-hero.jpg', ]); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'tiktok', 'video_url' => '/storage/videos/fastlane-video.mp4', ]); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'image', 'provider' => 'poster', 'url' => '/storage/images/fastlane-hero.jpg', ]); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'video', 'provider' => 'motion-video', 'url' => '/storage/videos/fastlane-video.mp4', ]); } public function test_generate_and_schedule_creates_recurring_campaign_when_repeat_enabled(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->andReturn([ 'title' => 'Campaign Repeat', 'caption' => 'Konten campaign harian.', 'hashtags' => ['#campaign'], 'cta' => 'Hubungi tim kami.', ]); }); $scheduledAt = now()->addHour()->startOfMinute(); $repeatUntil = $scheduledAt->copy()->addDays(3); $this->actingAs($user) ->post(route('social.generate-and-schedule'), [ 'topic' => 'Campaign repeat', 'tone' => 'friendly-professional', 'platform_hint' => 'facebook', 'platforms' => ['facebook'], 'scheduled_at' => $scheduledAt->toDateTimeString(), 'status' => 'scheduled', 'repeat_frequency' => 'daily', 'repeat_until' => $repeatUntil->toDateTimeString(), ]) ->assertRedirect(route('social.index')); $this->assertDatabaseHas('social_recurring_campaigns', [ 'user_id' => $user->id, 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Campaign Repeat', ]); $campaign = SocialRecurringCampaign::query()->firstOrFail(); $post = SocialPost::query()->firstOrFail(); $this->assertSame($campaign->id, $post->recurrence_campaign_id); $this->assertEquals($scheduledAt->toDateTimeString(), optional($post->recurrence_occurrence_at)?->toDateTimeString()); $this->assertSame('Campaign repeat', data_get($campaign->meta, 'generation.topic')); } public function test_generate_and_schedule_stores_brand_team_and_manual_approval_lane_for_recurring_campaign(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->andReturn([ 'title' => 'Lane Campaign', 'caption' => 'Recurring caption lane.', 'hashtags' => ['#lane'], 'cta' => 'Approve dulu.', ]); }); $this->actingAs($user) ->post(route('social.generate-and-schedule'), [ 'topic' => 'Campaign lane', 'tone' => 'friendly-professional', 'platform_hint' => 'x', 'platforms' => ['x'], 'scheduled_at' => now()->addHour()->startOfMinute()->toDateTimeString(), 'status' => 'scheduled', 'repeat_frequency' => 'daily', 'brand_label' => 'Medilis Cloud', 'team_label' => 'Growth', 'approval_mode' => 'manual', ]) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Konten AI berhasil dibuat dan langsung masuk ke queue automation.'); $this->assertDatabaseHas('social_recurring_campaigns', [ 'user_id' => $user->id, 'brand_label' => 'Medilis Cloud', 'team_label' => 'Growth', 'approval_mode' => 'manual', ]); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'draft', 'requires_approval' => true, ]); $post = SocialPost::query()->where('user_id', $user->id)->firstOrFail(); $this->assertSame('Medilis Cloud', data_get($post->meta, 'recurring_context.brand_label')); $this->assertSame('Growth', data_get($post->meta, 'recurring_context.team_label')); $this->assertSame('manual', data_get($post->meta, 'recurring_context.approval_mode')); } public function test_generate_and_schedule_rejects_instagram_without_image_url(): void { $user = User::factory()->create(); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->andReturn([ 'title' => 'Promo IG', 'caption' => 'Caption promo IG', 'hashtags' => ['#promo'], 'cta' => 'Lihat detailnya.', ]); }); $this->actingAs($user) ->from(route('social.index')) ->post(route('social.generate-and-schedule'), [ 'topic' => 'Promo instagram', 'tone' => 'friendly-professional', 'platform_hint' => 'instagram', 'platforms' => ['instagram'], 'scheduled_at' => now()->addHour()->toDateTimeString(), 'status' => 'scheduled', ]) ->assertRedirect(route('social.index')) ->assertSessionHas('social_error', 'Instagram membutuhkan image_url publik sebelum post bisa dibuat.'); $this->assertDatabaseCount('social_posts', 0); } public function test_enqueue_recurring_command_creates_next_queue_item(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['x'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Repeat X campaign', 'caption' => 'Konten harian X.', 'hashtags' => ['repeatx'], 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addMinutes(20)->startOfMinute(), 'meta' => [ 'bgm' => [ 'enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false, ], ], ]); $this->artisan('social:enqueue-recurring --limit=10 --window=60') ->assertSuccessful(); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'x', 'recurrence_campaign_id' => $campaign->id, 'status' => 'scheduled', ]); $campaign->refresh(); $this->assertNotNull($campaign->next_occurrence_at); $this->assertTrue($campaign->next_occurrence_at->gt(now()->addHours(23))); } public function test_enqueue_recurring_command_regenerates_ai_content_and_media_when_enabled(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['instagram', 'tiktok'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Old Title', 'caption' => 'Old caption', 'hashtags' => ['oldhash'], 'image_url' => '/storage/images/old.jpg', 'video_url' => '/storage/videos/old.mp4', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addMinutes(20)->startOfMinute(), 'meta' => [ 'bgm' => [ 'enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false, ], 'generation' => [ 'topic' => 'Promo AI repeat', 'tone' => 'friendly-professional', 'platform_hint' => 'instagram, tiktok', 'regenerate_content' => true, 'regenerate_media' => true, 'auto_generate_image' => true, 'auto_generate_video' => true, 'image_prompt' => 'Fresh repeat image prompt', 'video_prompt' => 'Fresh repeat video prompt', ], ], ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('Promo AI repeat', 'instagram, tiktok', 'friendly-professional') ->andReturn([ 'title' => 'Fresh Repeat Title', 'caption' => 'Fresh repeat caption', 'hashtags' => ['#freshrepeat'], 'cta' => 'Book demo baru.', ]); }); $this->mock(SocialMediaStudioService::class, function (MockInterface $mock): void { $mock->shouldReceive('generateImage') ->once() ->with('Fresh repeat image prompt') ->andReturn([ 'url' => '/storage/images/repeat-fresh.jpg', 'thumbnail_url' => '/storage/images/repeat-fresh-thumb.jpg', 'variants' => [[ 'url' => '/storage/images/repeat-fresh.jpg', 'thumbnail_url' => '/storage/images/repeat-fresh-thumb.jpg', 'width' => 1080, 'height' => 1920, ]], 'provider' => 'poster', 'prompt' => 'Fresh repeat image prompt', ]); $mock->shouldReceive('generateVideo') ->once() ->with('Fresh repeat video prompt') ->andReturn([ 'url' => '/storage/videos/repeat-fresh.mp4', 'provider' => 'motion-video', 'aspect' => '9:16', 'prompt' => 'Fresh repeat video prompt', 'duration_seconds' => 6, 'source_image_url' => '/storage/images/repeat-fresh.jpg', 'source_image_provider' => 'poster', ]); }); $this->artisan('social:enqueue-recurring --limit=10 --window=60') ->assertSuccessful(); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'instagram', 'title' => 'Fresh Repeat Title', 'image_url' => '/storage/images/repeat-fresh.jpg', 'recurrence_campaign_id' => $campaign->id, ]); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'tiktok', 'title' => 'Fresh Repeat Title', 'video_url' => '/storage/videos/repeat-fresh.mp4', 'recurrence_campaign_id' => $campaign->id, ]); $post = SocialPost::query() ->where('user_id', $user->id) ->where('platform', 'instagram') ->where('recurrence_campaign_id', $campaign->id) ->firstOrFail(); $this->assertSame("Fresh repeat caption\n\nBook demo baru.", $post->caption); $this->assertSame(['freshrepeat'], $post->hashtags); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'image', 'provider' => 'poster', 'url' => '/storage/images/repeat-fresh.jpg', ]); $this->assertDatabaseHas('social_media_assets', [ 'user_id' => $user->id, 'type' => 'video', 'provider' => 'motion-video', 'url' => '/storage/videos/repeat-fresh.mp4', ]); } public function test_enqueue_recurring_command_creates_alert_when_occurrence_generation_fails(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['instagram'], 'repeat_frequency' => 'daily', 'status' => 'active', 'brand_label' => 'Medilis Cloud', 'team_label' => 'Ops', 'approval_mode' => 'auto', 'title' => 'Broken Recurring', 'caption' => 'Old caption', 'hashtags' => ['oldhash'], 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addMinutes(20)->startOfMinute(), 'meta' => [ 'bgm' => [ 'enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false, ], 'generation' => [ 'topic' => 'Broken recurring', 'tone' => 'friendly-professional', 'platform_hint' => 'instagram', 'regenerate_content' => true, 'regenerate_media' => false, 'auto_generate_image' => false, 'auto_generate_video' => false, 'image_prompt' => '', 'video_prompt' => '', ], ], ]); $this->mock(SocialContentGeneratorService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->andThrow(new RuntimeException('Generator offline')); }); $this->artisan('social:enqueue-recurring --limit=10 --window=60') ->assertSuccessful(); $this->assertDatabaseHas('social_recurring_alerts', [ 'user_id' => $user->id, 'social_recurring_campaign_id' => $campaign->id, 'type' => 'enqueue_failed', 'message' => 'Generator offline', ]); $campaign->refresh(); $this->assertSame('Generator offline', data_get($campaign->meta, 'last_enqueue_error.message')); } public function test_pause_and_resume_recurring_campaign_updates_status(): void { $user = User::factory()->create(); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Pause Resume Campaign', 'caption' => 'Caption', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addHour(), 'meta' => [ 'bgm' => ['enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false], 'generation' => ['topic' => 'pause resume'], ], ]); $this->actingAs($user) ->post(route('social.recurring.pause', $campaign)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring campaign di-pause.'); $campaign->refresh(); $this->assertSame('paused', $campaign->status); $this->actingAs($user) ->post(route('social.recurring.resume', $campaign)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring campaign aktif lagi.'); $campaign->refresh(); $this->assertSame('active', $campaign->status); } public function test_run_now_recurring_campaign_creates_immediate_queue_item(): void { $user = User::factory()->create(); SocialAutomationSetting::create([ 'user_id' => $user->id, 'approval_mode' => 'auto', 'daily_limit' => 20, 'platform_daily_limits' => [ 'facebook' => 8, 'instagram' => 8, 'tiktok' => 4, 'x' => 12, ], 'max_retries' => 2, 'retry_delay_minutes' => 15, ]); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['x'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Run Now Campaign', 'caption' => 'Caption run now', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addDay(), 'meta' => [ 'bgm' => ['enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false], 'generation' => ['topic' => 'run now topic'], ], ]); $this->actingAs($user) ->post(route('social.recurring.run-now', $campaign)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring campaign langsung dibuatkan queue baru.'); $this->assertDatabaseHas('social_posts', [ 'user_id' => $user->id, 'platform' => 'x', 'recurrence_campaign_id' => $campaign->id, 'title' => 'Run Now Campaign', ]); } public function test_destroy_recurring_campaign_removes_campaign(): void { $user = User::factory()->create(); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'paused', 'title' => 'Delete Me', 'caption' => 'Delete caption', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addHour(), 'meta' => [ 'bgm' => ['enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false], 'generation' => ['topic' => 'delete me'], ], ]); $this->actingAs($user) ->delete(route('social.recurring.destroy', $campaign)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring campaign berhasil dihapus.'); $this->assertDatabaseMissing('social_recurring_campaigns', [ 'id' => $campaign->id, ]); } public function test_dismiss_recurring_alert_marks_it_as_read(): void { $user = User::factory()->create(); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Alert Campaign', 'caption' => 'Alert caption', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addHour(), ]); $alert = SocialRecurringAlert::create([ 'user_id' => $user->id, 'social_recurring_campaign_id' => $campaign->id, 'type' => 'enqueue_failed', 'title' => 'Recurring enqueue failed', 'message' => 'Provider timeout', 'context' => [ 'campaign_id' => $campaign->id, ], ]); $this->actingAs($user) ->post(route('social.recurring-alerts.dismiss', $alert)) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring alert ditandai selesai.'); $alert->refresh(); $this->assertNotNull($alert->read_at); } public function test_dashboard_displays_recurring_kpi_cards(): void { $user = User::factory()->create([ 'email_verified_at' => now(), ]); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'active', 'brand_label' => 'Medilis Cloud', 'team_label' => 'Growth', 'approval_mode' => 'manual', 'title' => 'Dashboard KPI Campaign', 'caption' => 'Caption', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addHours(2), ]); SocialRecurringAlert::create([ 'user_id' => $user->id, 'social_recurring_campaign_id' => $campaign->id, 'type' => 'enqueue_failed', 'title' => 'Recurring enqueue failed', 'message' => 'Dashboard alert sample', 'context' => [ 'campaign_title' => 'Dashboard KPI Campaign', ], ]); $this->actingAs($user) ->get(route('dashboard')) ->assertOk() ->assertSeeText('Campaign KPI and alert center') ->assertSeeText('Brand / Team Approval Lanes') ->assertSeeText('Recent Recurring Alerts') ->assertSeeText('Dashboard KPI Campaign') ->assertSeeText('Medilis Cloud / Growth'); } public function test_social_index_can_filter_recurring_campaigns_by_status(): void { $user = User::factory()->create(); SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Active Campaign Visible', 'caption' => 'Caption active', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addHour(), ]); SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['x'], 'repeat_frequency' => 'weekly', 'status' => 'paused', 'title' => 'Paused Campaign Visible', 'caption' => 'Caption paused', 'starts_at' => now()->subDay(), 'next_occurrence_at' => now()->addDays(2), ]); $this->actingAs($user) ->get(route('social.index', ['campaign_status' => 'paused'])) ->assertOk() ->assertSeeText('Paused Campaign Visible') ->assertDontSeeText('Active Campaign Visible'); } public function test_update_recurring_campaign_updates_blueprint_without_recreating(): void { $user = User::factory()->create(); $campaign = SocialRecurringCampaign::create([ 'user_id' => $user->id, 'platforms' => ['facebook'], 'repeat_frequency' => 'daily', 'status' => 'active', 'title' => 'Old Blueprint', 'caption' => 'Old caption', 'hashtags' => ['old'], 'image_url' => '/storage/images/old-edit.jpg', 'video_url' => null, 'starts_at' => now()->subDay()->startOfMinute(), 'next_occurrence_at' => now()->addHour()->startOfMinute(), 'meta' => [ 'bgm' => ['enabled' => false, 'provider' => 'freepik', 'prompt' => '', 'duration' => 15, 'volume' => 0.24, 'applied' => false], 'generation' => [ 'topic' => 'old topic', 'tone' => 'friendly-professional', 'platform_hint' => 'facebook', 'regenerate_content' => false, 'regenerate_media' => false, 'auto_generate_image' => false, 'auto_generate_video' => false, 'image_prompt' => '', 'video_prompt' => '', ], ], ]); $nextOccurrence = now()->addDays(2)->startOfMinute(); $repeatUntil = now()->addDays(10)->startOfMinute(); $this->actingAs($user) ->post(route('social.recurring.update', $campaign), [ 'platforms' => ['facebook', 'x'], 'title' => 'Updated Blueprint', 'caption' => 'Updated caption body', 'hashtags' => 'updated,blueprint', 'image_url' => '/storage/images/new-edit.jpg', 'video_url' => '/storage/videos/new-edit.mp4', 'starts_at' => now()->subDay()->startOfMinute()->toDateTimeString(), 'next_occurrence_at' => $nextOccurrence->toDateTimeString(), 'repeat_frequency' => 'weekly', 'repeat_until' => $repeatUntil->toDateTimeString(), 'topic' => 'new recurring topic', 'tone' => 'expert-friendly', 'platform_hint' => 'facebook, x', 'auto_generate_image' => '1', 'auto_generate_video' => '1', 'image_prompt' => 'Updated image prompt', 'video_prompt' => 'Updated video prompt', 'repeat_refresh_content' => '1', 'repeat_refresh_media' => '1', 'bgm_auto' => '1', 'bgm_prompt' => 'upbeat new bgm', 'bgm_duration' => 20, 'bgm_volume' => 0.30, ]) ->assertRedirect(route('social.index')) ->assertSessionHas('social_success', 'Recurring campaign berhasil diperbarui.'); $campaign->refresh(); $this->assertSame('Updated Blueprint', $campaign->title); $this->assertSame('weekly', $campaign->repeat_frequency); $this->assertSame(['facebook', 'x'], $campaign->platforms); $this->assertSame(['updated', 'blueprint'], $campaign->hashtags); $this->assertSame('/storage/images/new-edit.jpg', $campaign->image_url); $this->assertSame('/storage/videos/new-edit.mp4', $campaign->video_url); $this->assertSame('new recurring topic', data_get($campaign->meta, 'generation.topic')); $this->assertTrue((bool) data_get($campaign->meta, 'generation.regenerate_content')); $this->assertTrue((bool) data_get($campaign->meta, 'generation.regenerate_media')); $this->assertTrue((bool) data_get($campaign->meta, 'bgm.enabled')); $this->assertSame(20, data_get($campaign->meta, 'bgm.duration')); } public function test_scheduled_publish_command_auto_applies_bgm_and_preserves_existing_meta(): void { $user = User::factory()->create(); SocialPlatformCredential::create([ 'user_id' => $user->id, 'platform' => 'x', 'access_token' => 'token-x', ]); $post = SocialPost::create([ 'user_id' => $user->id, 'platform' => 'x', 'status' => 'scheduled', 'requires_approval' => false, 'caption' => 'Scheduled video campaign', 'video_url' => '/storage/videos/source.mp4', 'scheduled_at' => now()->subMinutes(5), 'meta' => [ 'campaign' => 'launch-q2', 'bgm' => [ 'enabled' => true, 'provider' => 'freepik', 'prompt' => 'upbeat electronic ads', 'duration' => 15, 'volume' => 0.24, 'applied' => false, ], ], ]); $this->mock(FreepikMusicService::class, function (MockInterface $mock): void { $mock->shouldReceive('generate') ->once() ->with('upbeat electronic ads', 15) ->andReturn([ 'url' => 'https://cdn.example.com/music/track.mp3', 'provider' => 'freepik-music', 'duration_seconds' => 15, ]); }); $this->mock(VideoMusicComposerService::class, function (MockInterface $mock): void { $mock->shouldReceive('compose') ->once() ->with('/storage/videos/source.mp4', 'https://cdn.example.com/music/track.mp3', 0.24) ->andReturn([ 'url' => '/storage/videos/source-mixed.mp4', 'relative_path' => 'videos/source-mixed.mp4', 'music_volume' => 0.24, ]); }); $this->mock(SocialPublisherService::class, function (MockInterface $mock): void { $mock->shouldReceive('publish') ->once() ->andReturn([ 'external_post_id' => 'tweet-789', 'payload' => ['ok' => true, 'channel' => 'x'], ]); }); $this->artisan('social:publish-scheduled --limit=5') ->assertSuccessful(); $post->refresh(); $this->assertSame('posted', $post->status); $this->assertSame('/storage/videos/source-mixed.mp4', $post->video_url); $this->assertSame('launch-q2', data_get($post->meta, 'campaign')); $this->assertTrue((bool) data_get($post->meta, 'bgm.applied')); $this->assertSame('/storage/videos/source-mixed.mp4', data_get($post->meta, 'bgm.mixed_video_url')); $this->assertTrue((bool) data_get($post->meta, 'publish_payload.ok')); $this->assertNotNull($post->approved_at); $this->assertSame('tweet-789', $post->external_post_id); } }