teknolis / tests /Feature /SocialAutomationTest.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
57.1 kB
<?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);
}
}