Download tests/Feature/SocialAutomationTest.php from medilis/teknolis: direct link, hf CLI and curl.
- Browser
- Download file 57.1 kB
-
https://huggingface.co/spaces/medilis/teknolis/resolve/main/tests/Feature/SocialAutomationTest.php
- Command line
-
hf download hf://spaces/medilis/teknolis/tests/Feature/SocialAutomationTest.php
-
curl -L -o SocialAutomationTest.php https://huggingface.co/spaces/medilis/teknolis/resolve/main/tests/Feature/SocialAutomationTest.php
57.1 kB
| 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); | |
| } | |
| } | |