Merge pull request #5 from trypost-it/fix/nullable-content-publishers

Fix nullable content across all social publishers
This commit is contained in:
Paulo Castellano 2026-03-29 17:34:52 -03:00 committed by GitHub
commit 557cd210a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 312 additions and 41 deletions

View file

@ -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

View file

@ -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,

View file

@ -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]);

View file

@ -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,

View file

@ -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',

View file

@ -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',

View file

@ -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,

View file

@ -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,
],

View file

@ -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()

View file

@ -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;

View file

@ -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');
});

View file

@ -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',

View file

@ -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');
});

View file

@ -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),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(\Exception::class, 'X posts require either text or media');
});
$result = $this->publisher->publish($this->postPlatform);
test('x publisher throws exception with null content and no media', function () {
$this->postPlatform->update(['content' => null]);
expect($result['id'])->toBe('1234567890123456789');
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 () {

View file

@ -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');
});