| <?php |
|
|
| namespace Tests\Feature; |
|
|
| use App\Models\SocialMediaAsset; |
| use App\Models\SocialAutomationSetting; |
| use App\Models\SocialPlatformCredential; |
| use App\Models\SocialPost; |
| use App\Models\SocialRecurringAlert; |
| use App\Models\SocialRecurringCampaign; |
| use App\Models\User; |
| use App\Services\FreepikMusicService; |
| use App\Services\SocialContentGeneratorService; |
| use App\Services\SocialRecurringCampaignService; |
| use App\Services\SocialMediaStudioService; |
| use App\Services\SocialPublisherService; |
| use App\Services\VideoMusicComposerService; |
| use Illuminate\Foundation\Testing\DatabaseTransactions; |
| use Illuminate\Http\UploadedFile; |
| use Illuminate\Support\Facades\Storage; |
| use Mockery\MockInterface; |
| use RuntimeException; |
| use Tests\TestCase; |
|
|
| class SocialAutomationTest extends TestCase |
| { |
| use DatabaseTransactions; |
|
|
| public function test_generate_failures_are_returned_as_flash_error(): void |
| { |
| $user = User::factory()->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); |
| } |
| } |
|
|