diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 90ea35b5..d5a61b9c 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -20,8 +20,6 @@ class ThreadsController extends SocialController protected array $scopes = [ 'threads_basic', 'threads_content_publish', - 'threads_manage_replies', - 'threads_read_replies', ]; public function connect(Request $request): Response|RedirectResponse diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index d09c6a21..5834671e 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -59,7 +59,7 @@ public function handle(): void $this->postPlatform->markAsFailed($e->getMessage()); $this->postPlatform->socialAccount->markAsDisconnected($e->getMessage()); - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error('Failed to publish to social platform', [ 'post_platform_id' => $this->postPlatform->id, 'platform' => $this->postPlatform->platform->value, diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index ac6adde4..3af810f1 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -48,10 +48,14 @@ public function publish(PostPlatform $postPlatform): array }; } - private function publishPost(string $pageId, string $accessToken, string $content, $media): array + private function publishPost(string $pageId, string $accessToken, ?string $content, $media): array { // Text only post if ($media->isEmpty()) { + if (empty($content)) { + throw new \Exception('Facebook text posts require content. Please add text to your post.'); + } + return $this->publishTextPost($pageId, $accessToken, $content); } @@ -101,7 +105,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co ]; } - private function publishSingleImagePost(string $pageId, string $accessToken, string $content, $media): array + private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array { Log::info('Facebook publishing single image post', ['page_id' => $pageId]); @@ -128,7 +132,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, str ]; } - private function publishMultiImagePost(string $pageId, string $accessToken, string $content, $mediaCollection): array + private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection): array { Log::info('Facebook publishing multi-image post', [ 'page_id' => $pageId, @@ -194,7 +198,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, stri ]; } - private function publishVideoPost(string $pageId, string $accessToken, string $content, $media): array + private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array { Log::info('Facebook publishing video post', ['page_id' => $pageId]); @@ -222,7 +226,7 @@ private function publishVideoPost(string $pageId, string $accessToken, string $c ]; } - private function publishReel(string $pageId, string $accessToken, string $content, $media): array + private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array { Log::info('Facebook publishing reel', ['page_id' => $pageId]); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 55c75ac1..63202ffa 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -56,7 +56,7 @@ public function publish(PostPlatform $postPlatform): array }; } - private function publishSingleImage(string $instagramId, string $accessToken, string $content, $media): array + private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array { Log::info('Instagram publishing single image', ['instagram_id' => $instagramId, 'image_url' => $media->url]); @@ -93,7 +93,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, st return $this->publishContainer($instagramId, $accessToken, $containerId); } - private function publishReel(string $instagramId, string $accessToken, string $content, $media): array + private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array { Log::info('Instagram publishing reel', ['instagram_id' => $instagramId]); @@ -167,7 +167,7 @@ private function publishStory(string $instagramId, string $accessToken, $media): return $this->publishContainer($instagramId, $accessToken, $containerId); } - private function publishCarousel(string $instagramId, string $accessToken, string $content, $mediaCollection): array + private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection): array { Log::info('Instagram publishing carousel', [ 'instagram_id' => $instagramId, diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 35776824..3860fab7 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -101,11 +101,11 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept } } - private function publishPost(string $organizationUrn, string $content, $media, $account): array + private function publishPost(string $organizationUrn, ?string $content, $media, $account): array { $payload = [ 'author' => $organizationUrn, - 'commentary' => $content, + 'commentary' => $content ?? '', 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', @@ -155,7 +155,7 @@ private function publishPost(string $organizationUrn, string $content, $media, $ ]; } - private function publishCarousel(string $organizationUrn, string $content, $mediaCollection, $account): array + private function publishCarousel(string $organizationUrn, ?string $content, $mediaCollection, $account): array { Log::info('LinkedIn Page publishing carousel', [ 'owner' => $organizationUrn, @@ -186,7 +186,7 @@ private function publishCarousel(string $organizationUrn, string $content, $medi $payload = [ 'author' => $organizationUrn, - 'commentary' => $content, + 'commentary' => $content ?? '', 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 914549b1..2eb4255b 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -94,11 +94,11 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept } } - private function publishPost(string $personUrn, string $content, $media): array + private function publishPost(string $personUrn, ?string $content, $media): array { $payload = [ 'author' => $personUrn, - 'commentary' => $content, + 'commentary' => $content ?? '', 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', @@ -142,7 +142,7 @@ private function publishPost(string $personUrn, string $content, $media): array ]; } - private function publishCarousel(string $personUrn, string $content, $mediaCollection): array + private function publishCarousel(string $personUrn, ?string $content, $mediaCollection): array { Log::info('LinkedIn publishing carousel', [ 'owner' => $personUrn, @@ -173,7 +173,7 @@ private function publishCarousel(string $personUrn, string $content, $mediaColle $payload = [ 'author' => $personUrn, - 'commentary' => $content, + 'commentary' => $content ?? '', 'visibility' => 'PUBLIC', 'distribution' => [ 'feedDistribution' => 'MAIN_FEED', diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 03b908a9..967377e8 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -47,6 +47,10 @@ public function publish(PostPlatform $postPlatform): array // Text only post if ($media->isEmpty()) { + if (empty($postPlatform->content)) { + throw new \Exception('Threads text posts require content. Please add text to your post.'); + } + return $this->publishTextPost($userId, $accessToken, $postPlatform->content); } @@ -91,7 +95,7 @@ private function publishTextPost(string $userId, string $accessToken, string $co return $this->publishContainer($userId, $accessToken, $containerId); } - private function publishImagePost(string $userId, string $accessToken, string $content, $media): array + private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array { Log::info('Threads publishing image post', ['user_id' => $userId, 'image_url' => $media->url]); @@ -122,7 +126,7 @@ private function publishImagePost(string $userId, string $accessToken, string $c return $this->publishContainer($userId, $accessToken, $containerId); } - private function publishVideoPost(string $userId, string $accessToken, string $content, $media): array + private function publishVideoPost(string $userId, string $accessToken, ?string $content, $media): array { Log::info('Threads publishing video post', ['user_id' => $userId]); @@ -151,7 +155,7 @@ private function publishVideoPost(string $userId, string $accessToken, string $c return $this->publishContainer($userId, $accessToken, $containerId); } - private function publishCarousel(string $userId, string $accessToken, string $content, $mediaCollection): array + private function publishCarousel(string $userId, string $accessToken, ?string $content, $mediaCollection): array { Log::info('Threads publishing carousel', [ 'user_id' => $userId, diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index effbfb1c..9db2c492 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -85,7 +85,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ 'post_info' => [ - 'title' => $postPlatform->content, + 'title' => $postPlatform->content ?? '', 'privacy_level' => 'SELF_ONLY', 'disable_duet' => false, 'disable_comment' => false, @@ -145,7 +145,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/content/init/", [ 'post_info' => [ - 'title' => $postPlatform->content, + 'title' => $postPlatform->content ?? '', 'privacy_level' => 'SELF_ONLY', 'disable_comment' => false, ], diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index d52d2e88..025dc888 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -37,9 +37,11 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $account->access_token; - $data = [ - 'text' => $postPlatform->content, - ]; + $data = []; + + if (! empty($postPlatform->content)) { + $data['text'] = $postPlatform->content; + } $mediaIds = []; $media = $postPlatform->media; @@ -69,6 +71,10 @@ public function publish(PostPlatform $postPlatform): array ]; } + if (empty($data['text']) && empty($mediaIds)) { + throw new \Exception('X posts require either text or media. Please add content to your post.'); + } + Log::info('Posting tweet', ['data' => $data]); $response = $this->getHttpClient() diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index a375bcba..b0f98458 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -68,6 +68,10 @@ private function getHttpClient(): PendingRequest private function publishShort(PostPlatform $postPlatform, $media): array { + if (empty($postPlatform->content)) { + throw new \Exception('YouTube Shorts require a title. Please add text to your post.'); + } + $title = $this->buildTitle($postPlatform->content); $description = $postPlatform->content; diff --git a/tests/Unit/Services/Social/FacebookPublisherTest.php b/tests/Unit/Services/Social/FacebookPublisherTest.php index 5871893a..8c567b1b 100644 --- a/tests/Unit/Services/Social/FacebookPublisherTest.php +++ b/tests/Unit/Services/Social/FacebookPublisherTest.php @@ -327,3 +327,35 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(Exception::class, 'Unsupported media type for Facebook'); }); + +test('facebook publisher throws exception for text post with null content', function () { + $this->postPlatform->update(['content' => null]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'Facebook text posts require content'); +}); + +test('facebook publisher can publish single image with null content', function () { + $this->postPlatform->update(['content' => null]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.facebook.com/v24.0/page_123/photos' => Http::response([ + 'id' => 'photo-123', + 'post_id' => 'post-123', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('post-123'); +}); diff --git a/tests/Unit/Services/Social/InstagramPublisherTest.php b/tests/Unit/Services/Social/InstagramPublisherTest.php index 7e4bbc83..aa93d95d 100644 --- a/tests/Unit/Services/Social/InstagramPublisherTest.php +++ b/tests/Unit/Services/Social/InstagramPublisherTest.php @@ -470,6 +470,145 @@ ->toThrow(Exception::class, 'Failed to create any carousel items'); }); +test('instagram publisher can publish single image with null content', function () { + $this->postPlatform->update(['content' => null]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'media-null-content', + ], 200), + 'https://graph.instagram.com/v24.0/media-null-content*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/NULL123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('media-null-content'); + expect($result['url'])->toBe('https://www.instagram.com/p/NULL123/'); +}); + +test('instagram publisher can publish reel with null content', function () { + $this->postPlatform->update([ + 'content_type' => ContentType::InstagramReel, + 'content' => null, + ]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1234567, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'reel-null-content', + ], 200), + 'https://graph.instagram.com/v24.0/reel-null-content*' => Http::response([ + 'permalink' => 'https://www.instagram.com/reel/NULL123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('reel-null-content'); +}); + +test('instagram publisher can publish carousel with null content', function () { + $this->postPlatform->update(['content' => null]); + + for ($i = 0; $i < 2; $i++) { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => "media/2026-01/test-image-{$i}.jpg", + 'original_filename' => "test-{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => $i, + ]); + } + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence() + ->push(['id' => 'child-1'], 200) + ->push(['id' => 'child-2'], 200) + ->push(['id' => 'carousel-container-123'], 200), + 'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'carousel-null-content', + ], 200), + 'https://graph.instagram.com/v24.0/carousel-null-content*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/CAROUSELNULL/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('carousel-null-content'); +}); + +test('instagram publisher can publish single image with empty string content', function () { + $this->postPlatform->update(['content' => '']); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'media-empty-content', + ], 200), + 'https://graph.instagram.com/v24.0/media-empty-content*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/EMPTY123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('media-empty-content'); +}); + test('instagram publisher handles publish failure', function () { $this->postPlatform->media()->create([ 'collection' => 'default', diff --git a/tests/Unit/Services/Social/ThreadsPublisherTest.php b/tests/Unit/Services/Social/ThreadsPublisherTest.php index aa10d043..4a996c71 100644 --- a/tests/Unit/Services/Social/ThreadsPublisherTest.php +++ b/tests/Unit/Services/Social/ThreadsPublisherTest.php @@ -292,3 +292,76 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(Exception::class, 'Threads media processing failed'); }); + +test('threads publisher throws exception for text post with null content', function () { + $this->postPlatform->update(['content' => null]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'Threads text posts require content'); +}); + +test('threads publisher can publish image with null content', function () { + $this->postPlatform->update(['content' => null]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.threads.net/v1.0/123456789/threads' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.threads.net/v1.0/container-123*' => Http::response([ + 'status' => 'FINISHED', + ], 200), + 'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([ + 'id' => 'media-123', + ], 200), + 'https://graph.threads.net/v1.0/media-123*' => Http::response([ + 'permalink' => 'https://threads.net/@testuser/post/123', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('media-123'); +}); + +test('threads publisher can publish video with null content', function () { + $this->postPlatform->update(['content' => null]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1234567, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.threads.net/v1.0/123456789/threads' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.threads.net/v1.0/container-123*' => Http::response([ + 'status' => 'FINISHED', + ], 200), + 'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([ + 'id' => 'video-123', + ], 200), + 'https://graph.threads.net/v1.0/video-123*' => Http::response([ + 'permalink' => 'https://threads.net/@testuser/post/456', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('video-123'); +}); diff --git a/tests/Unit/Services/Social/XPublisherTest.php b/tests/Unit/Services/Social/XPublisherTest.php index 8e4c3ae8..19d39b7c 100644 --- a/tests/Unit/Services/Social/XPublisherTest.php +++ b/tests/Unit/Services/Social/XPublisherTest.php @@ -156,24 +156,18 @@ }); }); -test('x publisher handles empty content', function () { +test('x publisher throws exception with empty content and no media', function () { $this->postPlatform->update(['content' => '']); - Http::fake([ - 'https://api.x.com/2/tweets' => Http::response([ - 'data' => [ - 'id' => '1234567890123456789', - ], - ], 200), - ]); - - $result = $this->publisher->publish($this->postPlatform); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'X posts require either text or media'); +}); - expect($result['id'])->toBe('1234567890123456789'); +test('x publisher throws exception with null content and no media', function () { + $this->postPlatform->update(['content' => null]); - Http::assertSent(function ($request) { - return $request['text'] === ''; - }); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'X posts require either text or media'); }); test('x publisher throws exception when no refresh token available', function () { diff --git a/tests/Unit/Services/Social/YouTubePublisherTest.php b/tests/Unit/Services/Social/YouTubePublisherTest.php index d7a955a1..00aca658 100644 --- a/tests/Unit/Services/Social/YouTubePublisherTest.php +++ b/tests/Unit/Services/Social/YouTubePublisherTest.php @@ -144,3 +144,20 @@ // Note: Testing token expiration on auth error would require mocking file_get_contents // which is used to fetch video content. The token refresh test above covers the token // expiration handling. Full integration tests should cover the 401 error scenario. + +test('youtube publisher throws exception with null content', function () { + $this->postPlatform->update(['content' => null]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1234567, + 'order' => 0, + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'YouTube Shorts require a title'); +});