diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 51e351ef..5fbfb4ea 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -9,6 +9,7 @@ use App\Exceptions\Social\FacebookPublishException; use App\Models\PostPlatform; use App\Services\Social\Concerns\HasSocialHttpClient; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -24,6 +25,15 @@ public function __construct() $this->baseUrl = config('trypost.platforms.facebook.graph_api'); } + /** + * Graph API expects application/x-www-form-urlencoded (or multipart), not JSON. + * Sending JSON makes `message` work but silently drops `attached_media[*]` on /feed. + */ + private function facebookHttp(): PendingRequest + { + return $this->socialHttp()->asForm(); + } + public function publish(PostPlatform $postPlatform): array { $this->validateContentLength($postPlatform); @@ -78,7 +88,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte private function publishTextPost(string $pageId, string $accessToken, string $content): array { - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ 'message' => $content, 'access_token' => $accessToken, ]); @@ -111,7 +121,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st $payload['message'] = $content; } - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload); + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload); if ($response->failed()) { Log::error('Facebook single image post failed', [ @@ -140,7 +150,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str continue; } - $uploadResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ + $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, @@ -175,7 +185,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str $postData["attached_media[{$index}]"] = json_encode($media); } - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); if ($response->failed()) { Log::error('Facebook multi-image post failed', [ @@ -205,7 +215,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ $payload['description'] = $content; } - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload); + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload); if ($response->failed()) { Log::error('Facebook video post failed', [ @@ -227,7 +237,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array { // Phase 1 (start) — graph endpoint returns video_id + upload_url. - $startResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ + $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -310,7 +320,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $finishPayload['description'] = $content; } - $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload); + $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload); if ($finishResponse->failed()) { $this->handleApiError($finishResponse); @@ -331,7 +341,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra if ($isVideo) { // Video story - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); @@ -347,7 +357,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Transfer the video (Facebook accepts URL in video_file_chunk) - $transferResponse = $this->socialHttp()->post("{$this->baseUrl}/{$videoId}", [ + $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ 'upload_phase' => 'transfer', 'video_file_chunk' => $media->url, 'access_token' => $accessToken, @@ -359,7 +369,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Finish the story - $finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ + $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'finish', 'video_id' => $videoId, 'access_token' => $accessToken, @@ -378,7 +388,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra } // Image story - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [ + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photo_stories", [ 'photo_id' => $this->uploadUnpublishedPhoto($pageId, $accessToken, $media), 'access_token' => $accessToken, ]); @@ -400,7 +410,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra private function uploadUnpublishedPhoto(string $pageId, string $accessToken, $media): string { - $response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ 'url' => $media->url, 'published' => 'false', 'access_token' => $accessToken, diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index e1afe22e..9fc2fd7b 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -126,7 +126,36 @@ expect($result['id'])->toBe('page_123_multi_post_789'); Http::assertSent(function ($request) { - return str_contains($request->url(), '/page_123/feed'); + return str_contains($request->url(), '/page_123/feed') + && str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded') + && ($request->data()['attached_media[0]'] ?? null) === json_encode(['media_fbid' => 'photo_1']) + && ($request->data()['attached_media[1]'] ?? null) === json_encode(['media_fbid' => 'photo_2']); + }); +}); + +test('facebook publisher sends graph api requests as form-urlencoded not json', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + ], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/page_123/photos') + && str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded') + && ! str_contains($request->header('Content-Type')[0] ?? '', 'application/json'); }); });